Articles / Systems Programming

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

Self-Hosting from the Countryside Behind CGNAT

My router's port forwarding wasn't broken. It simply didn't have a public IPv4 address to forward from. I once spent an entire evening convinced otherwise: 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. Somewhere upstream, my traffic was being folded into a pool shared with several hundred other customers. That's Carrier-Grade NAT, and you're almost certainly behind it, whether you've noticed or not, if your internet comes from:

  • Starlink
  • a 4G/5G home router
  • a lot of rural fixed-wireless ISPs

CGNAT puts a second NAT above the one you control

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. That's 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. 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. It's especially common 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.

Two minutes to confirm you're 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

You're behind CGNAT if either of these is true:

  • the address your router reports and the address these return are different
  • your router's address falls in 100.64.0.0/10

A quick traceroute to any public host will often show one or two extra hops with private-looking addresses right after your router. That's the ISP's NAT layer.

Quick detour: try IPv6 before building anything

Check whether the connection hands 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, you can skip the tunnel entirely and just use the AAAA record.

Client-side IPv6 is increasingly true for mobile networks and modern ISPs, less true for the person on a coffee shop wifi captive portal. The catch is that "increasingly true" isn't "always true". 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.

Stop accepting connections: dial out to a VPS instead

Since you can't accept inbound connections, the fix is to stop trying to. The shape of it:

  1. 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.
  2. Inbound traffic arrives at the VPS on a normal public address.
  3. 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 and has negligible overhead. Unlike a bare reverse SSH tunnel, it 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 the tunnel, step by step

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

Why PersistentKeepalive is the line that matters

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. 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.

Forwarding public traffic into the tunnel

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

Then enable forwarding on the VPS with sysctl -w net.ipv4.ip_forward=1 (made persistent in /etc/sysctl.conf), and point your DNS record at the VPS's public IP. Traffic now flows:

  1. client to VPS on the public internet
  2. VPS to home server down the WireGuard tunnel

That entirely bypasses the CGNAT layer that was blocking you.

What catches people out once it's running

  • 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.
  • 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.

Managed relays do the same job if you'd rather not run a VPS

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. 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.