Blog

Cloudflare Tunnel: The Safe Way to Expose Internal Services

Exposing a home web service to the internet through Cloudflare Tunnel

I started the way everyone does: port forwarding on the router, public:8443 → my home reverse proxy. One DNAT rule carried the blog for friends and remote access to my photo server. But this post isn't a feature list — it's the story of a migration: why one path stopped working, and how the other one took over.

About three months have passed since I shut down my last inbound port. The frustration has cooled off, the details haven't. Read this when you're starting to hate port forwarding but haven't decided to leave it yet.

The port-forwarding era: it worked, but always needed rescuing

The first phase lasted about six months. With the ONT in bridge mode and a real public IP on the main router, port forwarding behaved: open 443, point it at my internal Caddy, TLS certs from Let's Encrypt or SSL termination on the router itself.

Problems came in bunches. My ISP would occasionally flip me onto CGNAT, and my public IP went from "real" to "fake" overnight — the mapping was suddenly theater. The DDNS client was still reporting success while nobody from the outside could connect. And when I did have a public IP, the scanner fleets showed up immediately: brute force on 443, weird path probing, and occasional high-volume scans that saturated my uplink.

I tried alternate ports, country allowlists, and rate limiting piled onto the reverse proxy. Every layer blocked something, but one fact never changed: my home IP was sitting on the public internet. On a business trip in a hotel, SSHing in through yet another forwarded port, I felt worse about it every time.

One maintenance session I forgot to kill a "temporary" 8080 test mapping, and by the next day the logs were a party of unfamiliar user agents. That night I wrote down a rule: the fewer inbound holes, the better — ideally zero.

The turning point: CGNAT takes away the last bit of luck

What actually forced the migration was a two-week CGNAT stretch. Support was vague, and port forwarding was stone dead. I rigged a VPS reverse proxy that dialed home: nginx on the VPS pointing back over WireGuard, certs on the VPS, and my home origin listening on the LAN only. It looked like a ball of tangled yarn.

It worked. But every new hostname meant editing the VPS config, reloading, and poking at the firewall. If WireGuard dropped, the whole site went down, and debugging spanned two machines. I realized I was hand-rolling a worse Tunnel.

Meanwhile Cloudflare Tunnel had gotten solid: outbound connections, no inbound ports at home, edge certificates, and Access on top. The motivation shifted from "let's try the new toy" to "stop gambling on the ISP giving me a public IP."

Migration week: move the painless site first

I didn't cut everything over in a day. Week one was just the static blog.

I created the tunnel in the Cloudflare dashboard and ran cloudflared on the box with compose, token in .env:

services:
  tunnel:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run --token ${TUNNEL_TOKEN}

DNS flipped to Cloudflare with the orange cloud on. The origin listened on the LAN or localhost only, and I deleted the 80/443 inbound rules from the router. My fingers shook when I hit delete — like throwing away a security blanket I'd carried for years, even though that blanket had holes in it.

Once the blog was live, I crossed "check port mappings" off my weekly list. Its replacement: is the Tunnel online, is the origin container listening.

Week two: Access on the private services

Immich, Grafana, the dashboard services — an app password wasn't enough for those. I wrapped each hostname in Access: email OTP, or my own identity provider. After every policy change I verified it in an incognito window — unauthenticated requests get blocked at the edge, so far out you can't even casually load a login page.

Service tokens went to automation; I never expected scripts to use my personal login cookie. Once I typo'd the email domain in a policy and locked myself out — one denial reason in the Zero Trust logs, one edit, done. Easier to recover from than locking myself out of SSH: the control plane lives in the cloud, so it doesn't depend on me being able to reach my own home network.

Three buckets for hostnames: the heart of the migration checklist

I forced a classification in my notes to avoid the "let's hang everything off the same domain" mentality:

  1. Publicly readable: blog, static pages. Tunnel + normal caching is enough; keep admin interfaces out of the origin.
  2. Private but used daily: Immich, RSS, dashboards. Tunnel + Access.
  3. Never exposed: SSH, databases, router admin, NAS admin. Those go over WireGuard, not the Tunnel.

A breach in that third bucket risks the whole network. A tunnel being convenient doesn't mean every port belongs on a CDN. The easiest mistake during migration is sliding from "the Tunnel works" to "let's put SSH behind a web page too." I resisted.

Redividing the work with WireGuard

Need Pick after the move
Show a friend an HTTPS site Tunnel
Mount NFS / use SMB myself WireGuard
Quick demo of an internal web app Tunnel + Access
Manage SSH, switches WireGuard

The two coexist: WireGuard is the key that gets me into my LAN; the Tunnel hands a specific web app to the public internet safely. The old "public port table" from the port-forwarding era is archived in git history, no longer maintained.

Post-migration friction and leftover habits

When the origin binds to 127.0.0.1 and cloudflared runs in a different network namespace, it can't reach it — you have to use the Docker service name or the right host gateway. Under the orange cloud, Cloudflare handles the certs, so plain HTTP at the origin is fine. I once forced HTTPS at the origin and went a full circle before realizing it was pointless.

With several hostnames, one Tunnel runs multiple ingress rules, routing by hostname, with a catch-all that returns 404 so scanned paths don't reach some weird origin. Multiple tunnel replicas fight each other; a leaked token means rebuilding it. These all live in the ops notes now, not in "I'll remember in an emergency."

Three months after the move, the router's inbound rules are satisfyingly short. Shrinking the attack surface fits a home setup better than stacking WAF rules on a public IP. Port forwarding isn't outdated technology — the threat model and the ISP reality changed. What the Tunnel took over is the job of "the home connection no longer being the server's front door."

If you're still in the "port mapping works well enough" phase, don't migrate just to chase novelty. Wait until your first CGNAT or your first scanner scare, then open this post as your migration script. I've walked the path once; most of the potholes are marked above.

← Back to Blog