Cloudflare Tunnel Proxy: Self-Hosted VLESS+WebSocket+TLS

A complete tutorial for a self-hosted authenticated proxy that lets your phone egress from your home IP, even when you are on hostile WiFi or traveling abroad. The home machine has no public IP and no open ports. Cloudflare Tunnel carries the traffic; Xray (VLESS over WebSocket over TLS 1.3) does authentication and routing; v2RayTun on Android is the client. Includes architecture, threat model, build steps, five verification tests, always-on hardening, travel runbook, secret rotation, tear-down, troubleshooting, cost, and the alternatives I rejected (Tailscale, WireGuard, commercial VPN, Cloudflare WARP).

Cloudflare TunnelcloudflaredXrayVLESSWebSocketTLS 1.3v2RayTunHiddifySing-BoxDocker ComposeRootless DockerWSL2systemdUUID authDNS leakTravelBanking IPNo Public IP

1. What this is, and what it isn’t

Setup

The problem

When you travel, every banking, country-locked, or fraud-detecting app sees the IP of the WiFi or cellular network you are physically on. That triggers step-up MFA, blocks transactions, or simply refuses to load. The fix is to make those apps egress from the same residential IP they always see at home — without exposing the home machine to the public internet.

Goals

  • All phone traffic exits via your home ISP IP, regardless of where the phone is.
  • Zero open ports on the home machine. No port forwarding on the home router. No public IP needed (works behind CGNAT).
  • End-to-end TLS 1.3 from phone to the proxy. UUID-authenticated, with a 128-bit secret WebSocket path so random scans see only a 404.
  • Free: Cloudflare Tunnel free plan + your existing ISP + a domain you already own.
  • Survives reboots, power outages, and WSL2 quirks. Watchdog cron resurrects the stack within 5 minutes.

Non-goals

  • Not anti-censorship. If you live behind the Great Firewall, you want Reality, not vanilla VLESS+WS+TLS — although the same Xray binary supports both.
  • Not a corporate VPN. There is no SSO, no device posture, no DLP. It is a single-user appliance.
  • Not a kill switch. If the tunnel dies, the phone falls back to the local network. Use the verification tests in Section 6 every day while traveling.

2. Architecture and threat model

Architecture

End-to-end data path

Three machines, two trust boundaries, no inbound ports anywhere. The phone speaks VLESS-over-WebSocket-over-TLS to a Cloudflare anycast edge. Cloudflare’s edge forwards through a Cloudflare Tunnel that the home machine dialed outbound — there is no listening port on the home side. Inside the home machine, cloudflared hands off plain HTTP on a private Docker bridge to Xray, which authenticates the UUID, sniffs the destination, and finally egresses to the public internet from your home IP.

 +------------------------+     wss://proxy.example.com/<random-path>     +----------------------+
 | Android phone          |     ------ VLESS over WebSocket over TLS --- | Cloudflare edge      |
 | v2RayTun (TUN mode,    | -->                                          | (anycast, ~20 ms     |
 | captures all traffic)  |                                              |  from anywhere)      |
 +------------------------+                                              +----------+-----------+
                                                                                    |
                                                       Cloudflare Tunnel (outbound  |
                                                       dialed by cloudflared, no    |
                                                       inbound port at home)        |
                                                                                    v
 +-------------------------------------------------------------------------------------------------+
 |  Home machine  (WSL2 / Linux)                                                                   |
 |                                                                                                 |
 |   +---------------------------+   plain HTTP on docker bridge   +-----------------------------+ |
 |   | proxy-cloudflared         | ------------------------------> | proxy-xray                  | |
 |   | (cloudflare/cloudflared)  |   (proxy-net, host-only)        | (ghcr.io/xtls/xray-core)    | |
 |   | outbound dial to CF edge  |                                 | VLESS+WS server, UUID auth, | |
 |   | no inbound port           |                                 | blocks RFC1918 egress       | |
 |   +---------------------------+                                 +--------------+--------------+ |
 |                                                                                |                |
 |                                                                                v                |
 |                                                                  outbound TCP/UDP to internet — |
 |                                                                  egress IP is your residential  |
 |                                                                  IP. Banks, country-locked      |
 |                                                                  apps, etc., see this address.  |
 +-------------------------------------------------------------------------------------------------+

Adversary model

Adversary = anyone observing the WiFi / cellular network the phone is on, captive portals, hostile DNS resolvers, opportunistic eavesdroppers on the path between phone and Cloudflare. The threat model assumes the home machine itself is trusted; if root is compromised, the game is over.

Defenses

  • All traffic phone↔Cloudflare is TLS 1.3 (AES-256-GCM or CHACHA20-POLY1305).
  • All traffic Cloudflare↔home is mutually-authenticated tunnel encryption (cloudflared connector token).
  • Authentication is a 128-bit UUID — brute force is infeasible.
  • The WebSocket path is a 128-bit secret — random scans of proxy.example.com/ get a 404 and never see the WebSocket endpoint.
  • Xray’s routing rule blackholes any outbound to RFC1918 ranges — prevents SSRF into your home LAN.

Not defended against

  • Malware on the phone (it lives inside the tunnel).
  • Compromise of the home machine (root on the host = full game over).
  • Bank apps that fingerprint the TUN interface itself (rare, but possible).

3. Prerequisites

Setup

Accounts and infrastructure

  • A real domain in your own Cloudflare account. The free pages.dev won’t work — the tunnel hostname must live in a Cloudflare zone. Use a personal domain (.com, .co, .dev, etc.). Do not reuse a corporate Cloudflare account.
  • Linux machine that is online most of the time. This guide assumes Fedora in WSL2 on Windows; bare-metal Linux works identically; macOS works with minor path tweaks.
  • Cloudflare Global API Key from dash.cloudflare.com/profile/api-tokens → “Global API Key” → View. Treat it as a password. A scoped API token also works; the guide uses Global Key for simplicity.

Software on the host

  • Docker (rootless preferred — safer, slightly more set-up; rootful with sudo docker works the same).
  • docker compose plugin. Bundled with modern Docker installs.
  • Tooling: openssl, uuidgen, qrencode, curl, python3. On Fedora: sudo dnf install -y qrencode if missing.

Phone

Tested on Samsung S24 Ultra running Android 16. Any Android 7+ works. iOS works equally well with Streisand, Shadowrocket, or Hiddify — same vless:// share-link.

4. Build it from zero

How-to

4.1 Generate two secrets

Two cryptographic secrets are baked into the stack: a UUID that authenticates the client, and a 128-bit hex path that hides the WebSocket endpoint behind a “random scans get 404” mechanism. Both are generated locally with standard tools — no third-party service involved.

PROXY_UUID=$(uuidgen)
PROXY_WSPATH=$(openssl rand -hex 16)
echo "UUID=$PROXY_UUID"
echo "WSPATH=$PROXY_WSPATH"

Keep both in front of you; they go into the Xray config and the vless:// share-link.

4.2 Create the Xray config

VLESS speaks deliberately without app-layer encryption (decryption: none) because TLS does that work; the routing block blackholes RFC1918 destinations to prevent SSRF into the home LAN.

mkdir -p ~/proxy-stack/xray && cd ~/proxy-stack
cat > xray/config.json <<EOF
{
  "log": { "loglevel": "warning" },
  "inbounds": [
    {
      "tag": "vless-ws-in",
      "listen": "0.0.0.0",
      "port": 8080,
      "protocol": "vless",
      "settings": {
        "clients": [
          { "id": "${PROXY_UUID}", "level": 0, "email": "[email protected]" }
        ],
        "decryption": "none"
      },
      "streamSettings": {
        "network": "ws",
        "security": "none",
        "wsSettings": { "path": "/${PROXY_WSPATH}", "headers": {} }
      },
      "sniffing": {
        "enabled": true,
        "destOverride": ["http", "tls", "quic"],
        "routeOnly": false
      }
    }
  ],
  "outbounds": [
    { "tag": "direct",  "protocol": "freedom",   "settings": { "domainStrategy": "UseIPv4v6" } },
    { "tag": "blocked", "protocol": "blackhole", "settings": {} },
    { "tag": "dns-out", "protocol": "dns" }
  ],
  "dns": {
    "servers": ["1.1.1.1", "1.0.0.1", "8.8.8.8"],
    "queryStrategy": "UseIPv4v6"
  },
  "routing": {
    "domainStrategy": "AsIs",
    "rules": [
      { "type": "field", "ip": ["geoip:private"], "outboundTag": "blocked" },
      { "type": "field", "protocol": ["bittorrent"], "outboundTag": "blocked" }
    ]
  }
}
EOF

4.3 Create the docker-compose stack

Two services on a host-only bridge: cloudflared dials the Cloudflare edge (no inbound port) and proxies to xray on port 8080 inside the bridge. Logs are bounded so this never fills the disk during a long trip.

cat > docker-compose.yml <<'EOF'
name: proxy-stack

services:
  xray:
    image: ghcr.io/xtls/xray-core:latest
    container_name: proxy-xray
    restart: unless-stopped
    networks: [ proxy-net ]
    volumes:
      - ./xray/config.json:/etc/xray/config.json:ro
    command: ["run", "-c", "/etc/xray/config.json"]
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }

  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: proxy-cloudflared
    restart: unless-stopped
    networks: [ proxy-net ]
    depends_on: [ xray ]
    command: ["tunnel", "--no-autoupdate", "--metrics", "0.0.0.0:2000", "run", "--token", "${CF_TUNNEL_TOKEN}"]
    logging:
      driver: json-file
      options: { max-size: "10m", max-file: "3" }

networks:
  proxy-net:
    name: proxy-net
    driver: bridge
EOF

cat > .env <<EOF
PROXY_UUID=${PROXY_UUID}
PROXY_WSPATH=${PROXY_WSPATH}
PROXY_HOSTNAME=proxy.example.com
CF_TUNNEL_TOKEN=PASTE_TOKEN_AFTER_4.4
EOF
chmod 600 .env

4.4 Create the Cloudflare Tunnel via API

You can do this in the dashboard (Zero Trust → Networks → Tunnels → Create), but the API is faster, scriptable, and reproducible. The flow: verify the key, discover zone + account, create the tunnel, fetch the connector token, configure the public hostname and ingress rule, and create a CNAME pointing to the tunnel.

CF_KEY="<your Global API Key from dash.cloudflare.com/profile/api-tokens>"
CF_EMAIL="[email protected]"
DOMAIN="example.com"      # your zone
SUB="proxy"               # subdomain
HEAD=(-H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_KEY" -H "Content-Type: application/json")

# 1. Verify the key works
curl -sS "${HEAD[@]}" "https://api.cloudflare.com/client/v4/user" \
  | python3 -c "import sys,json;d=json.load(sys.stdin);print('user:', d['result']['email']) if d.get('success') else print('auth FAILED:', d.get('errors'))"

# 2. Find the zone and the account it belongs to
ZONE_JSON=$(curl -sS "${HEAD[@]}" "https://api.cloudflare.com/client/v4/zones?name=$DOMAIN")
ZONE_ID=$(echo "$ZONE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['id'])")
ACCOUNT_ID=$(echo "$ZONE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['account']['id'])")
ACCOUNT_NAME=$(echo "$ZONE_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['account']['name'])")
echo "Zone:    $ZONE_ID"
echo "Account: $ACCOUNT_ID  ($ACCOUNT_NAME)   <-- confirm this is YOUR personal account"

# 3. Create the tunnel
TUNNEL_SECRET=$(openssl rand -base64 32)
TUNNEL_JSON=$(curl -sS "${HEAD[@]}" -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/cfd_tunnel" \
  --data "{\"name\":\"proxy-stack\",\"tunnel_secret\":\"$TUNNEL_SECRET\",\"config_src\":\"cloudflare\"}")
TUNNEL_ID=$(echo "$TUNNEL_JSON" | python3 -c "import sys,json;print(json.load(sys.stdin)['result']['id'])")

# 4. Fetch the connector token
CF_TUNNEL_TOKEN=$(curl -sS "${HEAD[@]}" \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/cfd_tunnel/$TUNNEL_ID/token" \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['result'])")

# 5. Configure ingress: $SUB.$DOMAIN -> http://xray:8080
curl -sS "${HEAD[@]}" -X PUT \
  "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/cfd_tunnel/$TUNNEL_ID/configurations" \
  --data "{\"config\":{\"ingress\":[
    {\"hostname\":\"$SUB.$DOMAIN\",\"service\":\"http://xray:8080\"},
    {\"service\":\"http_status:404\"}
  ]}}" > /dev/null

# 6. Create the CNAME
curl -sS "${HEAD[@]}" -X POST \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
  --data "{\"type\":\"CNAME\",\"name\":\"$SUB\",\"content\":\"$TUNNEL_ID.cfargotunnel.com\",\"proxied\":true,\"ttl\":1}" > /dev/null

# 7. Persist values for tear-down / watchdog
echo "$CF_KEY" > cf_api_key.secret && chmod 600 cf_api_key.secret
sed -i "s|^CF_TUNNEL_TOKEN=.*|CF_TUNNEL_TOKEN=$CF_TUNNEL_TOKEN|"  .env
{ echo "CF_EMAIL=$CF_EMAIL"; echo "CF_ACCOUNT_ID=$ACCOUNT_ID"; echo "CF_ZONE_ID=$ZONE_ID"; echo "CF_TUNNEL_ID=$TUNNEL_ID"; } >> .env

4.5 Bring the stack online

Pull, start, and watch cloudflared register four HA connections to four different Cloudflare POPs. Then verify the public hostname returns a 404 (proves the chain is reachable) and that the secret WebSocket path returns HTTP/1.1 101 Switching Protocols.

docker compose pull
docker compose up -d
sleep 5
docker compose ps

# Watch cloudflared register its 4 HA connections to the CF edge
docker logs proxy-cloudflared 2>&1 | grep -i "Registered tunnel connection"
# Expected: 4 lines, each with a different CF POP (mia05, bog01, etc.)

# Catch-all 404 from xray (proves CF -> tunnel -> xray is reachable)
curl -sI https://proxy.example.com/ | head -3

# WS upgrade at the secret path -- must return 101
curl -i --http1.1 \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  https://proxy.example.com/$PROXY_WSPATH 2>&1 | head -5
# Expected: HTTP/1.1 101 Switching Protocols

5. Phone setup (Android)

Mobile

5.1 Pick a client

v2rayNG is the canonical client but it is not on Google Play. Any of the following work — they all accept the same vless:// share-link.

AppSourceWhy
v2RayTunplay.google.com/store/apps/details?id=com.v2raytun.androidLightest, closest to v2rayNG. Recommended.
Hiddify Nextplay.google.com/store/apps/details?id=app.hiddify.comCleaner UI.
Sing-Boxplay.google.com/store/apps/details?id=io.nekohasekai.sfaNewest engine, JSON config.

iOS: install Streisand, Shadowrocket, or Hiddify from the App Store — same share-link works.

5.2 Generate the share-link

The link encodes UUID, host, port, transport, and the secret WebSocket path. Render it as a QR code so the phone can scan it.

LABEL=$(printf 'proxy-stack@%s' "proxy.example.com" | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read()))')
PATH_ENC=$(printf '/%s' "$PROXY_WSPATH" | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read(),safe=""))')
SHARELINK="vless://${PROXY_UUID}@proxy.example.com:443?encryption=none&security=tls&sni=proxy.example.com&type=ws&host=proxy.example.com&path=${PATH_ENC}#${LABEL}"
echo "$SHARELINK" > share-link.txt
cat share-link.txt

# Render as QR for easy phone scan
qrencode -t UTF8i -m 2 -s 1 "$(cat share-link.txt)"

5.3 Import on the phone

  1. Open v2RayTun → tap the + in the top right → Scan QR code.
  2. Point the camera at the terminal QR. The profile imports as [email protected].
  3. Open Settings → DNS: set Remote DNS = 1.1.1.1, Domestic DNS = 1.1.1.1, enable Fake DNS. This is the strongest leak protection.
  4. Back on the home screen, tap the play button. Android prompts to allow the VPN connection — accept.
  5. Run the five tests in Section 6.

6. Verify the install — five real tests

Tests

Visit each URL with the proxy connected

Open Chrome on the phone with the proxy on and visit each URL below. All five must show your home country, IP, and ASN — never the country of the WiFi/cellular network you are physically on.

#URLExpected
1https://ifconfig.co/jsonip = your home public IP, country = your country, asn_org = your home ISP.
2https://1.1.1.1/cdn-cgi/traceloc = your country code, colo = the closest CF POP to home (mia05, bog01, etc.).
3https://browserleaks.com/dnsDNS Servers list shows Cloudflare or your home ISP — never the WiFi network’s resolver.
4https://www.dnsleaktest.com → Standard testSame outcome as test 3.
5https://ipv6-test.com/IPv4 only (we route IPv4) and no leaks.

If any test shows the wrong location, jump to Section 13 — Troubleshooting.

7. Operations

Ops

proxyctl.sh helper

Save this small wrapper next to docker-compose.yml. It exposes a single verb-based UX over the four daily operations: bring up, status, logs, and the share-link / QR generators.

cat > proxyctl.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
export DOCKER_HOST=${DOCKER_HOST:-unix:///run/user/1000/docker.sock}
[ -f .env ] && set -a && . .env && set +a

case "${1:-help}" in
  up)        docker compose pull && docker compose up -d && docker compose ps ;;
  down)      docker compose down ;;
  restart)   docker compose restart && docker compose ps ;;
  logs)      docker compose logs -f --tail=100 "${2:-}" ;;
  status)    docker compose ps ;;
  share-link)
    : "${PROXY_UUID:?}"; : "${PROXY_HOSTNAME:?}"; : "${PROXY_WSPATH:?}"
    LABEL=$(printf 'proxy-stack@%s' "$PROXY_HOSTNAME" | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read()))')
    PATH_ENC=$(printf '/%s' "$PROXY_WSPATH" | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read(),safe=""))')
    echo "vless://${PROXY_UUID}@${PROXY_HOSTNAME}:443?encryption=none&security=tls&sni=${PROXY_HOSTNAME}&type=ws&host=${PROXY_HOSTNAME}&path=${PATH_ENC}#${LABEL}"
    ;;
  qr) "$0" share-link | qrencode -t UTF8i -o - ;;
  *) echo "Usage: $0 {up|down|restart|logs [svc]|status|share-link|qr}" ;;
esac
EOF
chmod +x proxyctl.sh

Common operations: ./proxyctl.sh up, status, logs, logs xray, share-link, qr, restart, down.

Watchdog (cron, every 5 minutes)

The cloudflared container can stay Up while its tunnel is dead — rare, but it happens. Authoritative truth lives at the Cloudflare API. The watchdog asks Cloudflare directly, and if the tunnel reports anything other than healthy with at least one connection, it bounces the stack.

cat > watchdog.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
[ -f .env ] && set -a && . .env && set +a
export DOCKER_HOST=${DOCKER_HOST:-unix:///run/user/1000/docker.sock}
log() { echo "$(date -u +%FT%TZ) $*"; }
restart_stack() { log "restarting: $1"; docker compose restart >/dev/null 2>&1 || docker compose up -d >/dev/null 2>&1; }

docker ps --filter name=proxy-cloudflared --filter status=running --format '{{.Names}}' | grep -q proxy-cloudflared \
  || { restart_stack "cloudflared container down"; exit 0; }
docker ps --filter name=proxy-xray --filter status=running --format '{{.Names}}' | grep -q proxy-xray \
  || { restart_stack "xray container down"; exit 0; }

[ -z "${CF_ACCOUNT_ID:-}" ] || [ -z "${CF_TUNNEL_ID:-}" ] && exit 0
[ -f cf_api_key.secret ] || exit 0
CF_KEY=$(cat cf_api_key.secret)
STATUS=$(curl -sS --max-time 8 \
  -H "X-Auth-Email: ${CF_EMAIL:[email protected]}" -H "X-Auth-Key: $CF_KEY" \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$CF_TUNNEL_ID" \
  | python3 -c "import sys,json;d=json.load(sys.stdin)['result'];print(f\"{d.get('status','?')}|{len(d.get('connections',[]))}\")")
health=${STATUS%|*}; conns=${STATUS#*|}
if [ "$health" != "healthy" ] || [ "$conns" -lt 1 ]; then
  restart_stack "CF says status=$health conns=$conns"
fi
EOF
chmod +x watchdog.sh

# Install the cron entry (every 5 minutes, append output to watchdog.log)
( crontab -l 2>/dev/null; echo "*/5 * * * * $HOME/proxy-stack/watchdog.sh >> $HOME/proxy-stack/watchdog.log 2>&1" ) | crontab -

8. Always-on hardening

Ops

8.1 The boot chain (WSL2 specifically)

Windows boots
  -- Windows Task Scheduler runs "wsl --boot" task at SYSTEM startup
      -- WSL distro starts (because vmIdleTimeout=-1 in ~/.wslconfig)
          -- /etc/wsl.conf systemd=true -- systemd starts inside WSL
              -- [email protected] starts (because loginctl enable-linger)
                  -- ~/.config/systemd/user/docker.service starts (rootless dockerd)
                      -- proxy-xray + proxy-cloudflared resurrect (restart: unless-stopped)

8.2 Configure each link in the chain

# (a) WSL2 doesn't sleep
mkdir -p /mnt/c/Users/$USER 2>/dev/null
cat > /mnt/c/Users/$USER/.wslconfig <<'EOF'
[wsl2]
memory=64GB           # adjust to your RAM
processors=24         # adjust to your cores
vmIdleTimeout=-1      # never auto-suspend
EOF

# (b) systemd inside WSL (needed for rootless docker.service)
sudo tee /etc/wsl.conf >/dev/null <<EOF
[boot]
systemd=true

[user]
default = $USER
EOF

# (c) lingering -- user services run without an interactive session
sudo loginctl enable-linger $USER

# (d) install rootless docker (skip if you already have it)
curl -fsSL https://get.docker.com/rootless | sh
systemctl --user enable --now docker

# (e) restart WSL to pick everything up
# From PowerShell on Windows:    wsl --shutdown
# Then re-open WSL and verify:
systemctl --user is-enabled docker      # -> enabled
systemctl --user is-active  docker      # -> active
loginctl show-user $USER | grep Linger  # -> Linger=yes

8.3 Make Windows start WSL at boot, not just at login

Run this once in an Administrator PowerShell. The scheduled task fires at SYSTEM startup, before any human logs in, and parks an idle sleep infinity process inside WSL so the distro stays warm.

$action  = New-ScheduledTaskAction  -Execute "wsl.exe" -Argument "-d <YOUR-DISTRO> -u <YOUR-USER> -e bash -c 'sleep infinity'"
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest
Register-ScheduledTask -TaskName "WSL2-KeepAlive" -Action $action -Trigger $trigger -Principal $principal

Replace <YOUR-DISTRO> with the output of wsl -l -q and <YOUR-USER> with your WSL username.

8.4 BIOS — auto-power-on after grid restore

If the home machine loses power while you are away, Windows must boot itself when grid is restored. In BIOS / UEFI, find one of these settings (name varies by board) and enable it:

  • AC Power Recovery = Power On
  • Restore on AC/Power Loss = Last State or Power On
  • State After Power Failure = Always On

Verify before traveling. Pull the plug, plug back in, the machine should boot within ~30 seconds.

9. Security model — what is encrypted where

Security

Encryption layers, in order

  1. Phone ↔ Cloudflare edge: TLS 1.3, server-authenticated by the universal SSL cert that Cloudflare issues for your zone. AES-256-GCM or CHACHA20-POLY1305, ECDHE keys.
  2. Cloudflare edge ↔ cloudflared at home: the Cloudflare Tunnel itself, mutually-authenticated with the connector token (32-byte secret).
  3. cloudflared ↔ Xray inside Docker: plain HTTP on a host-only Docker bridge. This is the one unencrypted hop, by design — both endpoints live in the same machine, behind a private network namespace.
  4. Xray ↔ the wider internet: whatever the destination negotiates. HTTPS apps stay HTTPS; the proxy never sees their plaintext.

"encryption: none" in VLESS — explained

The share-link contains encryption=none&security=tls. People panic at the first part. It is fine. VLESS is the successor to VMess; it deliberately removes app-layer encryption because the transport layer (TLS) already provides it. Doing app-layer crypto on top of TLS used to be a fingerprint that censorship systems detected — VLESS killed that. The security=tls half of the share-link is what guarantees encryption.

echo | openssl s_client -connect proxy.example.com:443 \
  -servername proxy.example.com -tls1_3 2>/dev/null \
  | grep -E 'Protocol|Cipher|subject|issuer' | head -6
# Expect: Protocol: TLSv1.3, ECDHE+AES-256-GCM cipher

DNS encryption / leaks

DNS UDP queries from apps on the phone enter v2RayTun’s TUN, get encapsulated in VLESS-mux (which carries UDP inside the WebSocket), traverse to Xray, and Xray then resolves them out the home connection. DNS resolvers see queries from your home IP, not from the network the phone is on.

In v2RayTun Settings → DNS: Remote DNS = 1.1.1.1, Domestic DNS = 1.1.1.1 (force same — don’t bypass), Enable Fake DNS = ON (assigns synthetic IPs; server-side sniffing recovers the real domain — strongest leak protection).

10. Travel runbook

Ops

Failure modes & mitigations

ConcernMitigation
Home loses powerBIOS auto-on (Section 8.4) + ISP modem on UPS if possible
Home WiFi router restartsWatchdog cron resurrects containers; Cloudflare reconnects automatically
Cloudflare hostname goes staleWatchdog catches "status≠healthy" and bounces the stack
Phone runs out of batteryPowerbank in carry-on
WiFi captive portal blocks WSSUse mobile data; the tunnel only needs HTTPS so almost any network works
Phone is stolenRotate UUID + WSPATH (Section 11), re-import on new phone
You forget the share-link abroadStash it in your password manager BEFORE traveling. Or run ./proxyctl.sh share-link via SSH/Tailscale into the home box.

Before you leave

  1. Boot test: wsl --shutdown from PowerShell, wait 30 seconds, re-open WSL, run docker ps — both proxy containers should be Up.
  2. Hard reboot Windows. Wait 5 minutes. Don’t log in. From your phone (still at home, on different WiFi/mobile data), connect via v2RayTun and verify https://ifconfig.co.
  3. Pull the plug from the wall. Wait 30 seconds. Plug back in. Repeat the test.
  4. Save your share-link in a password manager. Save the CF tunnel ID and account ID too — useful if you need to recover anything remotely.

Once abroad

  • Connect v2RayTun before opening sensitive apps.
  • Run leak test #2 (https://1.1.1.1/cdn-cgi/trace) once a day. If loc ever shows the country you are in, the proxy isn’t carrying that traffic.
  • If a captive portal demands a login page, disconnect the proxy briefly, complete the captive portal in a browser, then reconnect.

11. Secret rotation

Security

Rotate UUID + WebSocket path

Do this if you think a secret leaked — share-link sent over plain email, lost phone, etc. The Cloudflare tunnel and DNS record stay the same; only the Xray-side authentication is regenerated, so no API calls are needed.

cd ~/proxy-stack
NEW_UUID=$(uuidgen)
NEW_PATH=$(openssl rand -hex 16)
OLD_UUID=$(grep '^PROXY_UUID=' .env | cut -d= -f2)
OLD_PATH=$(grep '^PROXY_WSPATH=' .env | cut -d= -f2)

sed -i "s|$OLD_UUID|$NEW_UUID|g" .env xray/config.json
sed -i "s|$OLD_PATH|$NEW_PATH|g" .env xray/config.json
./proxyctl.sh restart
./proxyctl.sh share-link    # re-import on the phone

Rotate the tunnel itself

If the connector token leaked, delete the tunnel and recreate it via API (re-run section 4.4 with the same domain and subdomain). The DNS CNAME is updated automatically because the tunnel ID changes.

12. Tear-down

Ops

Remove everything cleanly

Stop the containers, drop the cron watchdog, delete the DNS record, delete the tunnel, then wipe the working directory so all secrets disappear from disk.

# 1. Stop containers
cd ~/proxy-stack && docker compose down

# 2. Remove cron watchdog
crontab -l | grep -v "proxy-stack/watchdog.sh" | crontab -

# 3. Delete the CF tunnel + DNS record (use the .env values)
. ./.env
HEAD=(-H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $(cat cf_api_key.secret)" -H "Content-Type: application/json")

# Find and delete the DNS record
DNS_ID=$(curl -sS "${HEAD[@]}" \
  "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records?name=$PROXY_HOSTNAME" \
  | python3 -c "import sys,json;d=json.load(sys.stdin);print(d['result'][0]['id'] if d['result'] else '')")
[ -n "$DNS_ID" ] && curl -sS "${HEAD[@]}" -X DELETE \
  "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records/$DNS_ID" >/dev/null

# Delete the tunnel
curl -sS "${HEAD[@]}" -X DELETE \
  "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/$CF_TUNNEL_ID?cascade=true" >/dev/null

# 4. Wipe the working dir (kills all secrets)
cd ~ && rm -rf ~/proxy-stack

On the phone: delete the profile from v2RayTun. Done.

13. Troubleshooting cookbook

Ops

"v2RayTun says ‘connection refused’ / ‘EOF’"

Something rejected the TLS handshake. Check the tunnel’s view from outside:

curl -vI https://proxy.example.com/ 2>&1 | grep -E 'subject|issuer|HTTP'
# Cert subject must mention proxy.example.com or *.example.com

If the cert is for a different name, your zone’s "Universal SSL" hasn’t covered the subdomain yet — wait 10 minutes.

"WS upgrade returns 400 instead of 101"

You are sending HTTP/2 with Connection: Upgrade headers, which is illegal. Force HTTP/1.1 explicitly:

curl --http1.1 -H "Connection: Upgrade" ...

The phone client uses HTTP/1.1 already; this only matters for manual curl tests.

"Phone connects, but ifconfig.co shows the WiFi country"

v2RayTun connected, but routing is leaking. Check the routing rules on the phone — VPN mode should be Global / TUN, not split-tunnel. Disable any per-app whitelists. Re-run the five tests in Section 6.

"It worked yesterday, dead this morning"

The watchdog cron should have caught it. Check:

tail -50 ~/proxy-stack/watchdog.log
docker ps --filter name=proxy-

If WSL itself died (rare), wsl --shutdown from PowerShell, then re-open a WSL terminal — the KeepAlive task should resurrect it.

"Cloudflare tunnel shows ‘down’ in the dashboard"

Inspect the cloudflared logs and confirm the connector token still matches the one in the dashboard. If you ever revoked it, regenerate the token via API and update .env:

docker logs proxy-cloudflared --tail 100 | grep -iE 'error|unauth|registered'
docker compose restart cloudflared

14. Cost

Ops

Monthly cost breakdown

ItemMonthly
Cloudflare Tunnel (Free plan)$0
Cloudflare DNS / proxy on the zone$0
Bandwidth via Cloudflare edge$0 (covered by your ISP plan; CF does not meter tunnel bandwidth)
Compute on home machineElectricity for the always-on box
Domain renewal~$10/yr depending on TLD

Total ongoing: basically free, plus electricity. The biggest cost is the time spent on the initial setup, which this guide is designed to compress to roughly an evening.

15. Why I rejected the obvious alternatives

Architecture

Considered and rejected

AlternativeRejected because
Cloudflare WARP / Zero Trust + WARP ConnectorRequires a Linux server for the Connector; locks you into the Zero Trust UI; consumes one of the 50 free seats; pure phone WARP routes anywhere but does not egress from your home IP.
WireGuard via cloudflaredcloudflared cannot carry UDP; you would need Cloudflare Spectrum, which is paid.
OpenVPN over TCP via cloudflaredWorks, but requires the OpenVPN Connect app (heavier) and OpenVPN’s TCP mode is slow.
Plain SOCKS5 / HTTP proxyNo UDP support; not system-wide on Android without extra tools.
TailscaleExcellent product, but it is Tailscale, not Cloudflare — different trust boundary, different anycast network, different threat model.
Native Android VPN (IKEv2 / L2TP)Native UI is great, but these protocols need direct UDP exposure (port 500/4500), which defeats the "no public IP" requirement.
Self-hosted Headscale + WireGuardMost flexible long-term; more moving parts than CF Tunnel + Xray. Worth considering if you outgrow this setup.
Commercial VPN (NordVPN, Mullvad, etc.)Egresses from a VPN provider IP — banks treat that as suspicious and trigger MFA / blocks. The whole point here is to egress from your home residential IP.

The chosen stack (cloudflared + Xray VLESS+WS+TLS + v2RayTun) is the smallest set of moving parts that delivers full TCP/UDP/DNS, no public IP, mature mobile clients, and zero ongoing cost.