From Dropbox to Daily Driver Part 5: Cloudflare Tunnel, Zero Trust Access, and a WebSocket That Wouldn't Handshake
Every part of this series so far has assumed I’m sitting on the same local network as the Pi. Part 3 locked SSH down to keys only. Part 4 added a TOTP code on top of that key. Both are genuinely strong, but both also only matter once a connection reaches the device in the first place, and up to now that’s meant being on the same LAN or opening a port to get there. This part removes that assumption entirely: putting the Pi behind a Cloudflare Tunnel with a Zero Trust Access policy in front of it, so it’s reachable from anywhere, with no inbound port opened on my router at all.
Why a tunnel instead of a port forward
A port forward means my router accepts inbound connections from the entire internet on whatever port I choose, and everything protecting the Pi from that point on lives on the Pi itself. A Cloudflare Tunnel flips the direction: cloudflared running on the Pi opens an outbound-only connection to Cloudflare’s edge and holds it open. Nothing needs to reach in. The tunnel is how a client on the outside gets to my SSH port at all, and Zero Trust Access is the policy that decides who’s allowed to use that tunnel before the connection is even handed off to SSH.
That’s also the layer this series has been building toward since Part 3’s decision to skip fail2ban: fail2ban punishes repeated bad attempts after they’ve already reached sshd. Access refuses a connection before it reaches sshd at all.
Installing cloudflared and creating the tunnel
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb
cloudflared --version
cloudflared version 2026.8.2 (built 2026-08-14-12:17 UTC)
cloudflared tunnel login opens a browser link to authorise the tool against my Cloudflare account and domain, then drops a certificate locally:
cloudflared tunnel login
Please open the following URL and log in with your Cloudflare account:
https://dash.cloudflare.com/argotunnel?aud=...
Leave cloudflared running to download the cert automatically.
INF You have successfully logged in.
If you wish to copy your credentials to a server, they have been saved to:
/home/michealg/.cloudflared/cert.pem
Then the tunnel itself:
cloudflared tunnel create pi4-daily-driver
Tunnel credentials written to /home/michealg/.cloudflared/<tunnel-id>.json. cloudflared chose this file based on where your origin certificate was found. Keep this file secret. To revoke these credentials, delete the tunnel.
Created tunnel pi4-daily-driver with id <tunnel-id>
The tunnel ID and the credentials file are live authentication material, not device identifiers, so I’m not reproducing them here, the same treatment Part 4 gave the TOTP secret.
An ingress config maps a hostname to the local SSH port:
tunnel: <tunnel-id>
credentials-file: /home/michealg/.cloudflared/<tunnel-id>.json
ingress:
- hostname: pi4-128g.example.com
service: ssh://localhost:22
- service: http_status:404
The real hostname I’m actually using isn’t example.com, obviously, it’s a subdomain of this site’s own domain. I’m redacting the real one in this post for the same reason the tunnel ID is redacted: it’s a working address, not just a label.
cloudflared tunnel route dns pi4-daily-driver pi4-128g
INF Added CNAME pi4-128g.<my domain> which will route to this tunnel tunnelID=<tunnel-id>
That command creates the DNS record automatically. No router configuration, no port forward, nothing on the firewall.
A gotcha in running it as a service
Running cloudflared tunnel run in a foreground terminal proves the tunnel works, but it needs to survive a reboot, so the next step is installing it as a systemd service:
sudo cloudflared service install
Cannot determine default configuration path. No file [config.yml config.yaml] in [~/.cloudflared ~/.cloudflare-warp ~/cloudflare-warp /etc/cloudflared /usr/local/etc/cloudflared]
The config was sitting right there, at /home/michealg/.cloudflared/config.yml. The problem is where service install looks for it: it runs as root, and its default search checks ~/.cloudflared, which under sudo resolves to /root/.cloudflared, not my home directory. The file existed. It just wasn’t anywhere root was looking.
The fix is to stop relying on a user’s home directory for something that’s going to run as a system service, and move the config to the location cloudflared’s own documentation actually recommends for this:
sudo mkdir -p /etc/cloudflared
sudo cp ~/.cloudflared/<tunnel-id>.json /etc/cloudflared/
sudo cp ~/.cloudflared/config.yml /etc/cloudflared/config.yml
Then the credentials-file line in the copied config gets updated to match the new path, and the install succeeds:
sudo cloudflared service install
sudo systemctl status cloudflared
INF Using Systemd
INF Linux service for cloudflared installed successfully
● cloudflared.service - Cloudflare Tunnel client
Active: active (running)
INF | SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol. |
INF precheck component="DNS Resolution" details="DNS Resolved successfully" status=pass
INF precheck component="UDP Connectivity" details="QUIC connection successful" status=pass
INF precheck component="TCP Connectivity" details="HTTP/2 connection successful" status=pass
INF precheck component="Cloudflare API" details="API is reachable" status=pass
INF precheck complete hard_fail=false suggested_protocol=quic
Every precheck green. Worth flagging though: this precheck only confirms the tunnel can reach Cloudflare’s edge in general. It says nothing about whether any specific hostname is actually routed correctly, which turns out to matter later in this part.
The Zero Trust Access policy
The tunnel gets a connection to the Pi. Access decides who’s allowed to use it. In the Cloudflare Zero Trust dashboard, under Access, I added a self-hosted application pointed at the tunnel’s hostname, with a single policy: allow, restricted to my own email address. Anyone hitting that hostname gets sent to a login page and has to clear a one-time code sent to that address before the connection goes anywhere near SSH. The session lasts six hours before it needs to happen again, which matters later.
Wiring the client side
Both machines I connect from needed cloudflared installed locally too, since the client is what actually negotiates the Access login and proxies the connection through.
On box1:
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb
On Pro1, via winget:
winget install --id Cloudflare.cloudflared
That installed cleanly but cloudflared --version immediately after failed with command not found. Nothing wrong with the install, the MSI updates the system PATH, but the PowerShell window I already had open doesn’t reload environment variables from a change like that. Closing and reopening the terminal fixed it.
The SSH keypair from Part 3 lived in each machine’s home directory, not ~/.ssh/, so I moved it and tightened permissions:
mv ~/pi4_ed25519 ~/pi4_ed25519.pub ~/.ssh/
chmod 600 ~/.ssh/pi4_ed25519
Move-Item $HOME\pi4_ed25519, $HOME\pi4_ed25519.pub $HOME\.ssh\
icacls $HOME\.ssh\pi4_ed25519 /inheritance:r
icacls $HOME\.ssh\pi4_ed25519 /grant:r "$($env:USERNAME):(R)"
Windows OpenSSH refuses a private key with inherited, overly-open permissions the same way Linux does, just via ACLs instead of a mode bit.
Then an SSH config entry on both machines, using cloudflared access ssh as a ProxyCommand so the Access login happens automatically as part of connecting:
Host pi4-tunnel
HostName pi4-128g.example.com
ProxyCommand cloudflared access ssh --hostname %h
User michealg
IdentityFile ~/.ssh/pi4_ed25519
The bug: a valid login and a 502
The first real attempt from both machines failed identically:
websocket: bad handshake
Connection closed by UNKNOWN port 65535
That’s an unhelpful error on its own, and I didn’t want to guess at a fix, so I ran cloudflared access ssh on its own instead of through the SSH ProxyCommand, pointed at a local port, with debug logging on, so the actual HTTP response would be visible:
cloudflared access ssh --hostname pi4-128g.example.com --url localhost:2223 --loglevel debug
That showed the connection getting a real, signed Access token back after the browser login, with a valid email address and policy ID in it, meaning the identity and policy side of Access was working correctly. But the response to the actual WebSocket upgrade request that followed was:
DBG Websocket response: "HTTP/1.1 502 Bad Gateway\r\n..."
ERR failed to connect to origin error="websocket: bad handshake"
A 502 straight from Cloudflare’s edge, not from the Pi. At the same time, watching journalctl -u cloudflared -f on the Pi itself during the same attempt showed nothing at all, no new log line, no sign the request had arrived. Access had authenticated the login. The edge had a DNS route for the hostname and an Access application pointed at it. But the request never reached my tunnel’s connector.
The cause was in the ingress config, and it was a small one:
ingress:
- hostname: pi4-128g
That’s the short hostname, not the fully qualified one. The DNS route and the Access application both used the full pi4-128g.<domain>, matching what cloudflared tunnel route dns had actually created. The tunnel’s own ingress table was still looking for the unqualified name, so it had no rule matching what the edge was trying to route, and Cloudflare’s edge returned a 502 rather than ever handing the connection to a Pi that, as far as its own logs showed, was never asked.
Fixing it was a one-line change and a restart:
ingress:
- hostname: pi4-128g.example.com
sudo systemctl restart cloudflared
Confirming the full chain
$ ssh pi4-tunnel
The authenticity of host 'pi4-128g.example.com (<no hostip for proxy command>)' can't be established.
ED25519 key fingerprint is SHA256:<elided>
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'pi4-128g.example.com' (ED25519) to the list of known hosts.
(michealg@pi4-128g.example.com) Verification code:
Linux pi4-128g 6.18.34+rpt-rpi-v8 #1 SMP PREEMPT Debian 1:6.18.34-1+rpt1 (2026-06-09) aarch64
Last login: Tue Aug 18 2026 from 192.168.1.82
michealg@pi4-128g:~$
That’s the whole chain in one login: an Access-brokered connection, a first-time host key prompt because the tunnel’s hostname is new to this client, the SSH key from Part 3, then the TOTP prompt from Part 4. Both box1 and Pro1 confirmed it working the same way.
Then the actual test: connecting from a different internet connection entirely, with no route back to my home LAN at all. That worked immediately too, and without a second Access login, since the six-hour session from the first login was still valid. The policy is tied to an authenticated session, not to where the connection is coming from, which is exactly the point: identity decides access, not network location.
Tailscale and WireGuard: the other way to do this
Cloudflare Tunnel isn’t the only way to reach a device like this without opening a port, and it’s worth being honest about what it is and isn’t compared to the alternative most people reach for first.
Tailscale builds a mesh network on top of WireGuard. Every device that joins gets a private IP on that mesh and, depending on NAT traversal, direct peer-to-peer connections to every other device on it. There’s no public hostname involved at all unless you deliberately expose one. Access control is enforced by an ACL file describing which devices and users can reach which other devices, evaluated before a WireGuard handshake even completes.
What I built here is a different shape entirely. There’s no mesh, and no private IP for the Pi that other devices join. There’s a single public hostname, gated by an identity check that happens over HTTPS before the connection is handed off. Nothing about it requires installing Tailscale or WireGuard on every client, and nothing about it requires trusting a mesh network with every device that’s ever joined it. It also means the Pi is reachable from a browser or any Access-aware client without a persistent VPN client running in the background, which the mesh model generally assumes.
Neither is strictly better. A homelab with a dozen devices that all need to talk to each other benefits more from Tailscale’s mesh, since it’s solving a many-to-many problem. A single device that mostly needs one specific service reached occasionally, from wherever I happen to be, is a good fit for what Access does: gate one door with one identity check, and don’t build a network around it.
What’s next
The Pi is now reachable from anywhere, with an identity check in front of a key, in front of a TOTP code, and no listening port anywhere on my home network. Everything so far has assumed I’m the only one who’d ever notice if something went wrong. Part 6 changes that: standing up a monitoring layer that would actually tell me if it did.