-
Notifications
You must be signed in to change notification settings - Fork 0
The CGNAT Version
If your home internet is behind CGNAT, the standard "set up a port forward and point a DDNS hostname at it" remote-access playbook doesn't work. This page explains why, and how Tailscale routes around the problem without any ISP cooperation.
Carrier-Grade NAT is your ISP doing network address translation on top of the NAT your home router already does. Instead of every subscriber getting a unique public IPv4 address, hundreds (sometimes thousands) of subscribers share a single public IP, multiplexed by port.
The naming convention helps: CGNAT addresses live in the 100.64.0.0/10 range, reserved by RFC 6598 specifically for this purpose. If your WAN IP looks like 100.64.x.x, 100.95.x.x, or anywhere in that block, you're behind CGNAT.
Quick way to check:
# What your router thinks its WAN IP is (visible in the router admin UI)
# vs.
# What the public internet actually sees
curl ifconfig.meIf the two don't match AND your router's WAN IP starts with 100. (and isn't 100.127.255.x style local-IPv6-stuff), you're behind CGNAT.
Topology:
flowchart TB
Internet["Public Internet"]
ISP["ISP CGNAT gateway<br/>(one public IPv4 shared across subscribers)"]
Sub1["Subscriber 1<br/>Home router (NAT #1)"]
Sub2["Subscriber 2<br/>Home router (NAT #1)"]
SubYou["You<br/>Home router (NAT #1)"]
YourLAN["Your LAN devices<br/>(homelab, laptop, etc.)"]
Internet --- ISP
ISP --- Sub1
ISP --- Sub2
ISP --- SubYou
SubYou --- YourLAN
classDef shared fill:#fef3c7,stroke:#d97706,color:#222
class ISP shared
Your homelab sits behind two layers of NAT: your home router (normal home NAT), then your ISP's CGNAT gateway. From the public internet, there's no way to address you specifically. Your "public" IP belongs to the ISP, shared with everyone else on that gateway.
The short version: IPv4 addresses ran out.
The internet has about 4.3 billion IPv4 addresses. There are ~8 billion humans, each with multiple connected devices. The math doesn't math. Regional internet registries exhausted their free pools around 2011-2015 depending on region. Acquiring additional IPv4 blocks today means buying them from companies that don't need them anymore, at $30-50 per address. For an ISP with millions of subscribers, that's nine-figure money.
CGNAT defers the problem. By sharing a single public IP across hundreds of subscribers, the ISP needs ~1 IPv4 address per ~500 customers instead of one per customer. They keep selling service without buying expensive blocks.
Where you'll most commonly see it:
| ISP type | Typical CGNAT status |
|---|---|
| Mobile / 5G Home Internet (T-Mobile, Verizon, AT&T) | Almost always |
| Starlink | Default for residential |
| Apartment / condo bulk ISP | Often |
| Rural fiber / WISP | Frequently |
| Traditional cable / fiber (Comcast, Spectrum, AT&T fiber) | Sometimes; usually optional with static-IP add-on |
If you're on T-Mobile Home Internet or Starlink, you're behind CGNAT by default. There's no toggle in the customer portal that turns it off (with rare regional exceptions).
The long-term fix is IPv6, which has enough addresses (340 undecillion) to give every grain of sand on Earth its own subnet. Most ISPs support IPv6 alongside CGNAT-on-IPv4. But until every service you want to reach is dual-stack — which most aren't — the IPv4 path matters, and CGNAT is what you get.
Three things that normally work stop working:
The classic "open port 22 on the router to SSH home" pattern relies on your public IP belonging to you. With CGNAT, the public IP belongs to the ISP. Even if you set a port forward on your home router, inbound traffic from the internet never reaches the home router — it reaches the ISP's CGNAT gateway, which has no idea what to do with a port-22 connection on the shared address.
Dynamic DNS services map a hostname to whatever IP your home router reports. Behind CGNAT, the IP your router reports is its CGNAT-side address (a 100.x.x.x private address) or the shared public IP. Pointing myhomeserver.example.com at either one doesn't help anyone reach you.
OpenVPN, WireGuard, Tailscale's predecessor patterns: all assume the VPN server can receive an inbound connection on a known port. Behind CGNAT, there's no inbound. The VPN server can't listen for clients because the public internet can't reach it.
The practical consequence: you can browse the internet from your homelab (outbound works fine), but you can't reach your homelab from anywhere on the internet.
Tailscale's architectural trick is outbound-initiated mesh. Every device on the tailnet, regardless of where it lives, makes outbound connections to Tailscale's coordination server. CGNAT, hotel WiFi, mobile data, corporate firewall — none of them block outbound traffic on standard ports.
Once a device has registered with the coordination server, it knows about the other devices in the same tailnet and starts trying to talk to them.
flowchart TB
Coord["Tailscale Coordination Server<br/>(login.tailscale.com)"]
Laptop["Your laptop<br/>(anywhere)"]
Phone["Your phone<br/>(cellular)"]
Homelab["Your homelab<br/>(behind CGNAT)"]
Laptop -- "outbound" --> Coord
Phone -- "outbound" --> Coord
Homelab -- "outbound" --> Coord
Laptop -. "WireGuard mesh<br/>via NAT traversal" .- Homelab
Phone -. "WireGuard mesh<br/>via NAT traversal" .- Homelab
Laptop -. "WireGuard mesh<br/>via NAT traversal" .- Phone
classDef cgnat fill:#fef3c7,stroke:#d97706,color:#222
class Homelab cgnat
Two devices that need to talk to each other use NAT traversal. Three mechanisms in layers:
- STUN (Session Traversal Utilities for NAT) — each peer asks a public STUN server "what IP and port do you see me from?" and gets back its externally-visible endpoint. The two peers exchange this discovery via the coordination server.
- ICE (Interactive Connectivity Establishment) — rather than pre-characterizing every NAT type and picking a strategy, Tailscale probes all candidate endpoints simultaneously and picks the first that works. The Tailscale engineering team's own description of this is "try everything at once, and pick the best thing that works." Empirically more robust than trying to identify NAT type up front.
- DERP (Designated Encrypted Relay for Packets) — when ICE fails entirely, encrypted WireGuard packets are shuttled through a regional Tailscale-operated relay. The relay can't see the contents (end-to-end encrypted) but it can forward them. Slower than a direct connection, but it always works.
The cases where direct connection is hardest:
- Symmetric NAT — assigns a different external port for every destination, so the STUN-discovered port doesn't match the port your peer needs to send to. Birthday-paradox port-prediction sometimes works, but slowly. Symmetric NAT is common on cellular carriers and some CGNAT deployments.
- Hairpinning broken — when a NAT can't route a packet back to itself via its external IP, certain traversal paths fail. CGNAT gear is hit-or-miss on hairpinning; some implementations get it right, many don't.
In both cases, DERP is the safety net. It's the reason Tailscale works in environments where every other VPN architecture would give up.
If you want the deep technical version, Tailscale's blog post How NAT traversal works is one of the best networking writeups on the public internet. Worth reading regardless of whether you ever deploy Tailscale.
End result: your laptop can SSH into your homelab, your phone can stream from your Plex server, your tablet can reach Sonarr — all without any inbound connection from the public internet. Your ISP doesn't have to do anything. Your router doesn't need port forwards. CGNAT, formerly the boss-fight of self-hosting, becomes irrelevant.
The classic homelab playbook treats port forwarding as the goal and CGNAT as an obstacle. That's the wrong frame.
You don't need port forwarding. You need access to your stuff.
Port forwarding is just one path to that goal, and it carries a long list of dependencies:
- A public IP that belongs to you (CGNAT breaks this)
- An ISP that doesn't NAT you (CGNAT breaks this)
- A router you can configure (rentals, ISP gateways, locked-down firmware all break this)
- A DDNS service to track changing IPs (still doesn't help behind CGNAT)
- A degree of trust that nothing in this stack changes (ISP swaps you onto CGNAT one quiet Tuesday, your stack stops working)
And the security trade-off: every open port is an externally-reachable surface, scanned constantly by bots, indexed on Shodan, eventually probed by every CVE that drops in the next decade.
Tailscale doesn't recreate that path. It sidesteps the goal of "have a routable public IP" entirely. Every device dials home, the mesh forms outbound, access happens regardless of edge topology. No public ports. No DDNS. No scan surface.
The community has a more direct version of this argument: justfuckingusetailscale.com. Same thesis, more flair.
The default Tailscale mesh only works between devices that both have a Tailscale agent installed. If a relative wants to watch Plex from their iPad without installing anything, the basic mesh isn't enough on its own.
Tailscale Funnel fills that gap. Funnel exposes a single service on your tailnet to the public internet via Tailscale's relay network, at a *.<your-tailnet>.ts.net hostname with automatic HTTPS. From the consumer's side, it looks like a normal HTTPS URL. From your side, no port forwarding required, no public IP needed, works behind CGNAT.
This is the second half of the CGNAT escape: the tailnet handles devices you control (the *arr stack, your SSH access, your dev tools), and Funnel handles the one or two services you want to share with people outside your tailnet (most commonly, Plex).
The exact configuration is small but specific. Three places have to agree on what's exposed and where.
-
Enable the Tailscale prerequisites (one-time):
- Admin Console → DNS → HTTPS Certificates → Enable
- Admin Console → DNS → MagicDNS → Enable (usually on by default)
-
Install Tailscale on your Plex server if it isn't already, and join the tailnet.
-
Reset any existing Serve/Funnel config (skip if you've never configured it):
tailscale serve reset
-
Expose Plex via Funnel:
tailscale funnel --bg --https=443 localhost:32400
The
--bgmakes Funnel persist across reboots.--https=443tells Funnel to listen on the standard HTTPS port.localhost:32400is the local Plex Media Server port. -
Verify with
tailscale funnel status. You're looking for this exact shape:# Funnel on: # - https://your-host.your-tailnet.ts.net https://your-host.your-tailnet.ts.net (Funnel on) |-- / proxy http://localhost:32400One line per handler, direct from Funnel to
localhost:32400. If you see intermediate ports like:10000or a two-hop config (Funnel → some internal port → localhost:32400), the config is broken — back to step 3 and reset. -
Configure Plex Media Server:
- Settings → Remote Access → Disable. This is critical. Plex's built-in remote access will fight Funnel for the role of "how Plex clients reach this server." Disable it so Funnel is the sole path.
- Settings → Network → Custom server access URLs → set to:
Note the
https://your-host.your-tailnet.ts.net:443:443suffix, NOT:32400. Funnel terminates the public HTTPS connection on 443 and proxies internally to 32400. Putting:32400in this field sends Plex clients to the wrong port and they fail to connect. - Save and restart Plex Media Server.
-
Verify from a device that isn't on your tailnet (phone on cellular is the easiest test). Open the Plex app, sign in, and connect to your server. You should see it as available even though the client has no Tailscale agent installed.
After this pattern:
- Your family / friends / shared accounts can stream Plex without installing anything
- Your homelab still has zero public ports open (Plex's 32400 is bound to
localhostonly; Funnel is the only path in) - Your other services (Sonarr, Radarr, etc.) stay private behind the tailnet
- CGNAT is fully sidestepped — the same outbound mesh that powers Tailscale also powers Funnel
The cost: Funnel traffic goes through Tailscale's relays (not direct peer-to-peer), so there's some bandwidth overhead vs. a hypothetical direct connection. For Plex streaming this is rarely the bottleneck (the consumer's ISP and your uplink almost always dominate), but worth knowing.
Bypassing CGNAT for Plex with Tailscale Funnels is a clean, well-written walkthrough of this same pattern with screenshots of the Plex UI.
Honest boundaries even after Funnel:
-
Public services on a custom domain with no Tailscale infrastructure. If you want
myblog.example.comto resolve to your homelab from anywhere with no.ts.netURL anywhere in the chain, Funnel doesn't help. You'd want Cloudflare Tunnel or a similar outbound-tunneling load balancer. - Slow uplinks. CGNAT doesn't affect throughput, but the ISPs that lean heavily on CGNAT (mobile, Starlink) often have asymmetric or congested uplinks. Tailscale doesn't change physics.
- Multiple simultaneous public services on the same node via Funnel. Funnel is designed for "one service per node, on port 443." If you want to publicly expose multiple things from the same host, you'd run multiple Tailscale nodes or front everything with your own reverse proxy.
The architecture deployed in this repo works identically whether your home internet is on a static public IP, a dynamic public IP with DDNS, or hard CGNAT.
Tailscale doesn't care which one you're on. The app container makes outbound connections to the coordination server, your laptop's Tailscale agent does the same, and the WireGuard mesh between them is established without anyone needing to listen for inbound connections at the edge.
For T-Mobile Home Internet and Starlink users especially, this is the most important consequence of the whole setup. Before Tailscale, the canonical answer to "can I self-host on T-Mobile Home Internet?" was "no, not really, the CGNAT will eat you alive." After Tailscale, the answer is "yes, exactly the same way you'd do it on any other connection." Same docker-compose. Same ACL. Same Serve URLs. Same https://app.YOUR-TAILNET.ts.net/sonarr/.