vinnymarquez.dev/tutorials/getting around cgnat with a cloudflare tunnel
September 9, 20265 min readguide#homelab#networking#cloudflare#self-hosted

Getting Around CGNAT With a Cloudflare Tunnel

your ISP put you behind CGNAT, so a port-forward will never work. an outbound-only cloudflared connector gets a self-hosted service on the public internet anyway — one connector, many services, no inbound port.

i wanted a couple of self-hosted services reachable from outside the house — a media server for family, a request app that hangs off it. the normal answer is a port-forward on the router. that answer doesn’t exist for me, because my ISP runs CGNAT, and no amount of firewall config changes that.

this is the pattern that works anyway: an outbound-only tunnel. nothing listens on your WAN. the service reaches out to Cloudflare’s edge and Cloudflare routes public traffic back down that connection.

first, confirm it’s actually CGNAT

worth ruling in before you spend time on WAN firewall rules that can’t work:

if both point at CGNAT, a port-forward is off the table. not a config problem, a “there is no inbound path” problem. IPv6 is a possible exception if your ISP hands out real (non-CGNAT) v6 and the client also has v6 — unreliable enough that a tunnel is the better answer regardless.

the shape of it

piece where it runs what it does
cloudflared connector a container/VM on your LAN, next to the services opens an outbound connection to Cloudflare, proxies incoming requests to localhost:<port> on the LAN
Cloudflare Tunnel Cloudflare Zero Trust dashboard the named endpoint the connector registers against
Public Hostname route on the tunnel maps service.yourdomain.comhttp://<lan-ip>:<port>
DNS record auto-created by Cloudflare CNAME for the hostname, pointed at the tunnel

one connector can serve many services as long as they’re reachable from the box it runs on — you add a route per hostname, not a connector per service.

step 1 — install cloudflared as a service

on the LAN box that can reach your services (a small dedicated container is ideal). install the real package + register it as a system service — not the Docker image, unless you’re already all-in on Docker there:

# Debian/Ubuntu
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main" | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install -y cloudflared

you’ll connect it to a specific tunnel in step 3 — the dashboard gives you a one-line cloudflared service install <token> to run, which drops the credentials and enables the systemd unit in one go.

step 2 — create the tunnel

Cloudflare dashboard → Zero Trust → Networks → Tunnels → Create a tunnelCloudflared → name it something you’ll recognise (home-tunnel).

it shows you an install command with an embedded token. run that on the box from step 1:

sudo cloudflared service install eyJ...long-token...
systemctl status cloudflared   # want: active (running)

back in the dashboard the tunnel should flip to Healthy within a few seconds.

the connector box has to actually reach the serviceno route, no proxy
cloudflared proxies to whatever address you give the route from the connector's own network position. if the connector is on a different VLAN/subnet than the service with no inter-VLAN rule permitting it, the tunnel is Healthy and the route still 502s. test from the connector box directly first: curl -sI http://<lan-ip>:<port> should return something before you add the route.

step 3 — add a public hostname route

on the tunnel → Published application routes (not “Private network” / “Hostname routes” — those are for clients running Cloudflare’s WARP agent, a different model) → Add a published application route:

field value notes
Subdomain service e.g. media
Domain yourdomain.com must be a zone already on Cloudflare DNS
Path (blank) unless you’re routing by path
Service type HTTP
URL <lan-ip>:<port> the plain LAN address of the service

Cloudflare auto-creates the DNS record for the hostname. no manual CNAME.

http:// vs https:// on the origin URL502 if you get it wrong
most self-hosted apps serve plain HTTP on their own port. set the route's service to http://<lan-ip>:<port>. point an https:// origin at a plain-HTTP app and the TLS handshake between cloudflared and the app fails — Cloudflare returns a 502 that looks like the app is down. only use https:// (with 'No TLS Verify' turned on) if the app genuinely serves its own cert, e.g. one from an internal CA.
the app rejects the request by Host header400 from the app, not from Cloudflare
some apps validate the incoming Host header against an allowlist and 400 anything else — the tell is the error page is the app's, not Cloudflare's. the fix is app-specific: a env var or config key naming the allowed hostnames (Homepage uses HOMEPAGE_ALLOWED_HOSTS, for example). add the new public hostname there.

step 4 — reuse the connector for the next service

no new tunnel, no new cloudflared. same tunnel → Published application routes → Add another route, new subdomain, new <lan-ip>:<port>. as long as the connector box can reach it, it works. i run one connector for several services this way.

step 5 — verify

the fresh DNS record won't resolve on your own machineresolves fine on 1.1.1.1 / 8.8.8.8, not locally
if you run Tailscale on the machine you're testing from, its MagicDNS stub resolver (100.100.100.100) can cache the NXDOMAIN from before the record existed, and it sits below the browser so incognito doesn't help. restarting the Tailscale client doesn't clear it. turn Tailscale off on that client to fall back to normal DNS — or just wait out Cloudflare's negative-cache TTL (~30 min), it self-heals.
media server still shows 'offline' after setting the custom URLa legacy auto-port-forward feature fighting you
the tunnel can be reachable (curl to the identity/health endpoint works) while the native app still says the server is offline. if the app has its own legacy 'remote access' / UPnP port-forward feature, it may be appending a detected external port onto every published connection URL — including your clean tunnel URL — and nothing listens on that port through the tunnel. disable that feature, then re-save the custom URL field (re-saving is what triggers the re-announce; a restart alone often isn't). leave it disabled — it does nothing useful behind CGNAT.

the one real tradeoff

traffic proxies through Cloudflare’s network instead of a direct connection. for most things that’s invisible. for sustained high-bitrate streaming it’s worth knowing about — some media server docs explicitly call out proxied connections as not ideal for that. if that’s your use case and it bites, the answer isn’t “go back to port-forwarding” (still can’t), it’s a direct path over IPv6 or a mesh VPN for the clients that need it, with the tunnel as the fallback for everyone else.

where this sits vs a mesh VPN

a per-device mesh VPN (Tailscale and friends — see exposing a service over tailscale) solves the same “no inbound port” problem. the tunnel wins when the people using the service aren’t all on your mesh and you’d rather hand them a URL than ask them to enrol a device. the mesh wins for admin access to things that should never be on the public internet at all. they’re not mutually exclusive — i run both, for different audiences.

▌ comments