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).
1. What this is, and what it isn’t
SetupThe 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
ArchitectureEnd-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
SetupAccounts and infrastructure
- A real domain in your own Cloudflare account. The free
pages.devwon’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 dockerworks the same). docker composeplugin. Bundled with modern Docker installs.- Tooling:
openssl,uuidgen,qrencode,curl,python3. On Fedora:sudo dnf install -y qrencodeif 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-to4.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)
Mobile5.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.
| App | Source | Why |
|---|---|---|
| v2RayTun | play.google.com/store/apps/details?id=com.v2raytun.android | Lightest, closest to v2rayNG. Recommended. |
| Hiddify Next | play.google.com/store/apps/details?id=app.hiddify.com | Cleaner UI. |
| Sing-Box | play.google.com/store/apps/details?id=io.nekohasekai.sfa | Newest 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
- Open v2RayTun → tap the + in the top right → Scan QR code.
- Point the camera at the terminal QR. The profile imports as
[email protected]. - 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. - Back on the home screen, tap the play button. Android prompts to allow the VPN connection — accept.
- Run the five tests in Section 6.
6. Verify the install — five real tests
TestsVisit 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.
| # | URL | Expected |
|---|---|---|
| 1 | https://ifconfig.co/json | ip = your home public IP, country = your country, asn_org = your home ISP. |
| 2 | https://1.1.1.1/cdn-cgi/trace | loc = your country code, colo = the closest CF POP to home (mia05, bog01, etc.). |
| 3 | https://browserleaks.com/dns | DNS Servers list shows Cloudflare or your home ISP — never the WiFi network’s resolver. |
| 4 | https://www.dnsleaktest.com → Standard test | Same outcome as test 3. |
| 5 | https://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
Opsproxyctl.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
Ops8.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 OnRestore on AC/Power Loss=Last StateorPower OnState 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
SecurityEncryption layers, in order
- 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.
- Cloudflare edge ↔ cloudflared at home: the Cloudflare Tunnel itself, mutually-authenticated with the connector token (32-byte secret).
- 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.
- 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
OpsFailure modes & mitigations
| Concern | Mitigation |
|---|---|
| Home loses power | BIOS auto-on (Section 8.4) + ISP modem on UPS if possible |
| Home WiFi router restarts | Watchdog cron resurrects containers; Cloudflare reconnects automatically |
| Cloudflare hostname goes stale | Watchdog catches "status≠healthy" and bounces the stack |
| Phone runs out of battery | Powerbank in carry-on |
| WiFi captive portal blocks WSS | Use mobile data; the tunnel only needs HTTPS so almost any network works |
| Phone is stolen | Rotate UUID + WSPATH (Section 11), re-import on new phone |
| You forget the share-link abroad | Stash it in your password manager BEFORE traveling. Or run ./proxyctl.sh share-link via SSH/Tailscale into the home box. |
Before you leave
- Boot test:
wsl --shutdownfrom PowerShell, wait 30 seconds, re-open WSL, rundocker ps— both proxy containers should beUp. - 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. - Pull the plug from the wall. Wait 30 seconds. Plug back in. Repeat the test.
- 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. Iflocever 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
SecurityRotate 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
OpsRemove 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
OpsMonthly cost breakdown
| Item | Monthly |
|---|---|
| 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 machine | Electricity 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
ArchitectureConsidered and rejected
| Alternative | Rejected because |
|---|---|
| Cloudflare WARP / Zero Trust + WARP Connector | Requires 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 cloudflared | cloudflared cannot carry UDP; you would need Cloudflare Spectrum, which is paid. |
| OpenVPN over TCP via cloudflared | Works, but requires the OpenVPN Connect app (heavier) and OpenVPN’s TCP mode is slow. |
| Plain SOCKS5 / HTTP proxy | No UDP support; not system-wide on Android without extra tools. |
| Tailscale | Excellent 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 + WireGuard | Most 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.