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:
- check your router’s WAN interface address. if it’s in
100.64.0.0/10, that’s the carrier-grade NAT range — the address the internet sees is a different one, held by another NAT layer at the ISP that your router can’t see or control. canyouseeme.org(or any external port checker) against a port you’ve forwarded — it’ll report closed no matter what your router says.
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.com → http://<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 tunnel → Cloudflared → 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
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
the app rejects the request by Host header400 from the app, not from Cloudflare
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
curl -sI https://service.yourdomain.com/from off your network — expect a response from the app (a 200, a redirect to its login, whatever it normally does), not a Cloudflare error page- the service’s own LAN URL still works locally — the tunnel adds a path, it doesn’t replace the direct one
- for anything with a built-in “external URL” setting (media servers especially), set it to the new hostname and re-save
the fresh DNS record won't resolve on your own machineresolves fine on 1.1.1.1 / 8.8.8.8, not locally
media server still shows 'offline' after setting the custom URLa legacy auto-port-forward feature fighting you
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.