Zero Open Ports — Deploying This Site Through a Pi and a Cloudflare Tunnel
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.