Zero Open Ports — Deploying This Site Through a Pi and a Cloudflare Tunnel

Raspberry Pi #raspberry-pi#cloudflare#github-actions#nginx#deployment#meta

How My Nerdy Life actually gets from a git push to your browser: GitHub Actions, a Raspberry Pi, and a Cloudflare Tunnel that means the Pi never opens a single port to the internet. Including the part where I forgot which machine I was even talking to.

This post is dogfooding in the most literal sense: the pipeline it describes is the same one that just deployed it. If you’re reading this on mynerdylife.com, it worked.

The shape of it

push to main


GitHub Actions — npm ci → npm run build → dist/
     │  rsync over SSH, tunnelled through Cloudflare Access

Cloudflare edge ──► cloudflared on the Pi ──► sshd

                                    rsync writes /var/www/mynerdylife

visitors ─► mynerdylife.com ─► Cloudflare ─► cloudflared ─► Nginx :80

The part I like: the Pi never opens an inbound port. Not 80, not 443, not 22. cloudflared runs as a systemd service and makes an outbound connection to Cloudflare’s edge, which handles both the public website traffic and the SSH channel GitHub Actions uses to ship new builds. Anyone port-scanning my home network finds nothing.

The one-time setup, done for real

The GitHub Actions workflow, Nginx config, and tunnel config had all been scaffolded in an earlier session — but scaffolding a deploy pipeline and actually standing it up are very different amounts of done. This session was the “wire it to real infrastructure” part: a fresh Debian install on the Pi, no nginx, no cloudflared, no deploy user, nothing.

A few things went sideways in genuinely funny ways:

I wasn’t where I thought I was. Told my assistant “we’re currently on the Pi.” uname -a disagreed — the shell was on my MacBook the whole time. Small thing, but it would’ve quietly wasted the next ten steps if nobody checked.

The Pi’s IP had drifted. SSH tried 192.168.50.47, then .48 — both timeouts. Turned out my Mac was on a completely different subnet (192.168.86.x) than those stale addresses. Once I gave the current IP, SSH refused it outright with Host key verification failed — which sounds like a break-glass emergency but was actually good news: the key being offered from the new address matched the key already trusted for the Pi’s old addresses. Same physical machine, just reassigned. Safe to pin and move on.

I’d forgotten a passphrase. There was already a pi.pub key sitting in ~/.ssh from a previous attempt, named macbook-to-pi — clearly meant for exactly this. Its private key was passphrase-protected, and I had no idea what the passphrase was. Rather than fight it, I generated a fresh passphrase-free keypair and moved on. Not every dead end needs solving.

Cloudflare’s tunnel login didn’t need port forwarding. Older guides for cloudflared tunnel login describe janky SSH-tunneling-the-callback dances because the CLI used to wait for a browser redirect to hit a local port. The current version just prints a hosted callback URL — open it, authorize, done. Nice bit of the tool getting easier since the docs were written.

Cloudflare Access gated a selector I needed before I had the thing it needed. Setting the SSH hostname’s Access policy to allow “Service Auth” required a Service Token to already exist — the selector sat greyed out until one did. Create the token first, then the option unlocks.

A 403 was the best possible error. First real request to mynerdylife.com came back 403 Forbidden — Nginx’s own “directory has no index file” response, headers and all matching my config exactly. That meant Cloudflare, the tunnel, and Nginx were all correctly wired end to end; there was just nothing to serve yet. A bad error would’ve been a timeout or a Cloudflare error page. This was just an empty room with the lights on.

Proving it before trusting it

Before letting GitHub Actions touch production, I replayed its exact steps by hand from my own machine — same service token, same SSH ProxyCommand, same key — and had it drop a test file straight into /var/www/mynerdylife:

TUNNEL_SERVICE_TOKEN_ID="…" TUNNEL_SERVICE_TOKEN_SECRET="…" \
ssh -o ProxyCommand="cloudflared access ssh --hostname %h" \
    -i gha_deploy_key [email protected] \
    'whoami && echo test > /var/www/mynerdylife/_probe.txt'

Once that came back clean, I trusted the workflow to do the same thing without a human watching.

Shipping it

Three GitHub secrets (PI_SSH_KEY, CF_ACCESS_CLIENT_ID, CF_ACCESS_CLIENT_SECRET), one gh workflow run deploy.yml, and:

mynerdylife.com     -> HTTP 200
www.mynerdylife.com -> HTTP 200
<title>My Nerdy Life</title>

Every push to main now builds and deploys itself. The Pi still doesn’t know anyone’s watching it — which is exactly the point.