---
title: "Zero Open Ports — Deploying This Site Through a Pi and a Cloudflare Tunnel"
description: "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."
url: "https://mynerdylife.com/blog/zero-open-ports-deploy-pipeline/"
author: "Jared"
published: "2026-07-29"
category: "Raspberry Pi"
tags: ["raspberry-pi","cloudflare","github-actions","nginx","deployment","meta"]
---

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

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`:

```bash
TUNNEL_SERVICE_TOKEN_ID="…" TUNNEL_SERVICE_TOKEN_SECRET="…" \
ssh -o ProxyCommand="cloudflared access ssh --hostname %h" \
    -i gha_deploy_key deploy@ssh.mynerdylife.com \
    '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.
