Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Systems Programming

Published:

Tags:
  • cgnat
  • wireguard
  • starlink
  • networking
  • self-hosting
  • vpn

Rural Broadband and CGNAT: Why Self-Hosting Needs a Reverse Tunnel

I once spent an entire evening convinced my router's port forwarding UI was broken. I'd opened port 443, pointed it at a little Caddy instance running a status page, and nothing was reaching it from outside. Not a firewall issue, not a typo, not the ISP blocking the port. The router simply didn't have a public IPv4 address to forward from. Somewhere upstream, my traffic was being folded into a pool shared with several hundred other customers. That's Carrier-Grade NAT, and if your internet comes from Starlink, a 4G/5G home router, or a lot of rural fixed-wireless ISPs, you're almost certainly behind it whether you've noticed or not.

What CGNAT actually does

Ordinary home broadband usually gives your router one public IPv4 address, and your router does NAT for the devices behind it. That's NAT44, one layer, and it's why port forwarding on a normal router works: the router owns the address, so it can decide what an inbound packet on port 443 should do.

CGNAT adds a second layer of NAT upstream, inside the ISP's network, before your traffic ever reaches the public internet. Your router gets an address from the range reserved in RFC 6598, 100.64.0.0/10, which is not a public address at all: it's a "shared address space" specifically carved out for this purpose. That address is itself behind a NAT device run by the ISP, which multiplexes potentially thousands of customers onto a much smaller pool of real public IPv4 addresses.

The practical effect: your router has no public address to forward a port on, because it doesn't have a public address at all. Any port forwarding rule you configure only rewrites traffic between your LAN and your router's CGNAT address; it does nothing to the layer above, which you don't control and can't see into.

This isn't a bug or an oversight. IPv4 address exhaustion is real and CGNAT is the standard way ISPs stretch a shrinking pool of addresses across a growing number of connections, especially on mobile and satellite networks where customer churn and scale make buying more IPv4 space expensive. Starlink does it, most UK 4G/5G home broadband does it, and it's increasingly common on fixed-wireless rural ISPs too.

Checking whether you're actually behind it

Don't take my word for the diagnosis, it's easy to confirm. Log into your router and find the WAN IP it thinks it has, then compare it with what the outside world sees:

curl -s https://ifconfig.me
curl -s https://api.ipify.org

If the address your router reports and the address these return are different, or your router's address falls in 100.64.0.0/10, you're behind CGNAT. A quick traceroute to any public host will often show one or two extra hops with private-looking addresses right after your router, which is the ISP's NAT layer.

One thing worth checking before building anything: does the connection hand out a working IPv6 address? Starlink generally does, and a fair few 4G routers do too. If your home server has a routable IPv6 address and whatever's connecting to it also has IPv6 (increasingly true for mobile networks and modern ISPs, less true for the person on a coffee shop wifi captive portal), you can skip the tunnel entirely and just use the AAAA record. The catch is that "increasingly true" isn't "always true", and self-hosting something you want reliably reachable from anywhere usually means you can't bet on the client side having IPv6. It's worth trying first because it costs nothing, but don't be surprised if it's not a complete answer.

The reverse tunnel model

Since you can't accept inbound connections, the fix is to stop trying to. Instead, your home server makes an outbound connection to a machine that does have a stable public IP, a small VPS, and keeps that connection open. Inbound traffic arrives at the VPS on a normal public address, and the VPS forwards it down the existing tunnel back to your home network. From the internet's perspective, the VPS is the server. From your network's perspective, it's a pipe back home.

WireGuard is a good fit for this because it's simple to reason about, has negligible overhead, and (unlike a bare reverse SSH tunnel) gives you a proper virtual network interface on both ends, so you're forwarding at the IP layer rather than juggling per-port SSH forwards.

Building it

Get the cheapest VPS you can find with a public IPv4 address, install WireGuard on it and on the home server, and generate keys on both:

wg genkey | tee privatekey | wg pubkey > publickey

On the VPS, /etc/wireguard/wg0.conf:

[Interface]
PrivateKey = VPS_PRIVATE_KEY
Address = 10.10.0.1/24
ListenPort = 51820

[Peer]
PublicKey = HOME_PUBLIC_KEY
AllowedIPs = 10.10.0.2/32

On the home server:

[Interface]
PrivateKey = HOME_PRIVATE_KEY
Address = 10.10.0.2/24

[Peer]
PublicKey = VPS_PUBLIC_KEY
Endpoint = vps.example.com:51820
AllowedIPs = 10.10.0.1/32
PersistentKeepalive = 25

That PersistentKeepalive line matters more here than in a normal WireGuard setup. The whole point of this design is that the home server is the one dialling out, because it's the only side that can. The CGNAT layer maintains a translation entry for that outbound connection, but only while it sees regular traffic; UDP mappings on NAT devices typically expire after somewhere between 30 seconds and a few minutes of silence. Without the keepalive, your tunnel would quietly die every time it went idle, and you'd only notice when someone tried to reach your service and got nothing.

Bring both interfaces up with wg-quick up wg0, and confirm the link works with a ping between 10.10.0.1 and 10.10.0.2.

The last piece is getting inbound traffic from the VPS's public interface into the tunnel. The simplest option is to just run your service bound to the WireGuard address on the home server, and DNAT to it on the VPS:

nft add table ip nat
nft add chain ip nat prerouting { type nat hook prerouting priority -100 \; }
nft add rule ip nat prerouting iif "eth0" tcp dport 443 dnat to 10.10.0.2:443

and enable forwarding on the VPS with sysctl -w net.ipv4.ip_forward=1 (made persistent in /etc/sysctl.conf). Point your DNS record at the VPS's public IP, and traffic now flows: client to VPS on the public internet, VPS to home server down the WireGuard tunnel, entirely bypassing the CGNAT layer that was blocking you.

Things that catch people out

The VPS is now your single point of failure and the visible target for anything aimed at your service; treat its firewall and patching the same way you would any internet-facing box, because it is one. If your home connection drops, the tunnel drops with it and there's no way for the VPS to reach back in, since it never could in the first place; that's an inherent property of this model, not a bug in the config. And if you're running more than one service, you'll want more DNAT rules or a reverse proxy on the VPS listening on 443 and routing by SNI/hostname down the tunnel, rather than opening a new WireGuard peer or port per service.

If running your own VPS and maintaining nftables rules is more than you want to own, Tailscale's Funnel feature and Cloudflare Tunnel solve the same underlying problem with a managed relay instead of one you run yourself. The tradeoff is you're trusting someone else's infrastructure to sit in front of your traffic, and you give up some control over ports and routing in exchange for not having to patch a VPS at 2am. For a lot of home labs that's a perfectly reasonable trade; for anything where you want to own the whole path, the WireGuard approach above costs about the price of the smallest VPS you can find and an evening of setup.