A tunnel for each server, and no open web ports

My home server and the small cloud machines around it are reached, for web traffic, through Cloudflare. Each machine opens its own tunnel outward, and the tunnel is its only route in for that traffic. I’d like to say this was all principle. Part of it was the house.

Partly forced on the house

The router at home sits behind carrier-grade NAT. There is no fixed public address, and nothing on the internet can open a connection to a port on the home server, whatever the firewall says. Port forwarding is not a choice I declined; it is a thing that cannot work here.

So the home server, cs, runs a tunnel connector that dials out to Cloudflare and keeps the connection open. Requests come down it. Nothing at home listens to the internet, and that shape was fixed before I had an opinion about it. The opinion came later, when I built the cloud machines the same way on purpose.

One tunnel per machine

The cloud machines each run their own connector: auth, the sign-in service; watch, monitoring and alerts; api, the NFL prediction platform in production; and id, the newer sign-in service. Each connector is outbound-only and publishes no ports, and each is that machine’s only route in for web traffic.

Update, 23 October 2026: the household sign-in moved to its own machine, id, in October 2026, and auth now runs only the old sign-in until it is retired.

I could have run one tunnel on one machine and forwarded to the rest over private links. The reason not to is the same reason the monitoring lives off-site: no machine should depend on another to be reachable. An alert about cs must not need cs to deliver it, and a sign-in page must not need the monitoring box to be up.

“One tunnel per host” overstates it, though, and I want to be exact. Every machine whose web traffic comes through Cloudflare either runs its own tunnel or accepts web traffic only from Cloudflare’s published address ranges. The machine that hosts my WordPress sites, wp, is the second kind: its cloud firewall admits web traffic from those ranges and from nowhere else. And plexfront, the small machine in front of my media server, has no tunnel at all, because its whole job is to stay off Cloudflare’s proxy. That one has its own post.

The ingress map is a file

For cs, the tunnel’s ingress map, which hostname goes to which service with which settings, is a file in git. A script pushes it to Cloudflare. It diffs the live configuration against the file before it writes, backs the live configuration up first, and only then applies. The file is the source of truth and the dashboard is a copy of it, rather than the other way round.

The cloud machines’ tunnels are configured the other way: remotely, in the dashboard, with no file on the machine. That is a deliberate asymmetry and an honest gap. The home server’s ingress carries the Access settings described in Checking Cloudflare Access twice and changes often enough to need a history someone else can check; the droplets’ maps are small and rarely change. But “in the dashboard” means “not in git”, and I don’t love it.

What the firewalls still admit

“No inbound ports” would be false for the cloud machines, and I’d rather not say it. Their cloud firewalls admit SSH and ping from anywhere, and the WireGuard port only on machines where a private link needs it. Everything outbound is allowed, because the tunnel connector, package updates and image pulls all need to dial out. What is true is narrower: the web services accept no traffic except through their tunnel.

Two more layers sit behind that. The host firewall stays on as a second wall, which matters because a port published by Docker bypasses the host firewall on its own. And the SSH daemon takes keys only, allows an explicit list of users, permits few attempts, and refuses root.

The trade-off I accepted is that SSH is reachable from the whole internet, key-only. The alternative was SSH over a private link or through the tunnel, which would mean that losing the link or the tunnel also loses the way in to fix it. I kept the direct door and hardened it instead.

The cache rule

One more rule travels with every hostname on the home server’s tunnel. A cache rule on the zone bypasses Cloudflare’s cache for all of them, and a new hostname joins that list when it is added. The services behind the tunnels are private and answer differently to every visitor, and the rule makes “never cached” a property of the list rather than of each service’s headers.

Watching it

Each machine’s checkout is compared with git every hour, and a row per machine on the public status page goes red when they differ. The same page shows the sign-in services and the backups, so if a tunnel or a machine disappears, it shows there before anyone notices in a browser.

What it costs

Cloudflare is in the path of everything web. That is a dependency I chose, and it has a price: when the tunnel connector or Cloudflare has a bad day, every remote path has one too, and on the LAN only the services with a local DNS route keep working. The ingress for the cloud machines lives in a dashboard rather than a file. And the shape at home was never a decision, only a consequence I later agreed with. I think the result is right, but I’d rather be accurate about how it got here than tidy.

← All writing