Skip to content

Reaching a home server behind CGNAT with WireGuard

If your ISP puts you behind CGNAT (carrier-grade NAT), your router's "public" IP isn't yours — it's shared, and there is no inbound path at all. Port forwarding is not merely disabled; there is nothing to forward to. Dynamic DNS doesn't help either: it fixes the name → address problem, not the no-inbound problem.

The fix is to stop trying to accept connections at home. Rent the smallest VPS you can, and have the home server dial out to it. That outbound connection is a tunnel, and once it's up, traffic flows in both directions through it.

home server ──── outbound UDP ────▶ VPS (real public IP)
   (CGNAT)  ◀─── both directions ───    ▲
                          laptop / phone, anywhere

The VPS is a rendezvous point, not a proxy. It needs almost no resources — a tunnel like this idles at a few MB of RAM.

The mental model

Three ideas explain almost every WireGuard question.

1. It's layer 3 over UDP — names and proxies don't apply

WireGuard moves IP packets inside UDP datagrams. A peer is identified purely by its public key. There is no Host header, no SNI, no TLS handshake, no notion of a hostname on the wire:

client:  wg.example.com  --DNS-->  203.0.113.10   then UDP to 203.0.113.10:51820
server:  receives a UDP packet, checks the key. Never learns any hostname.

So do I point a subdomain at it, and does nginx need to route it?

Subdomain: yes, and that's the whole job. One A record wg.example.com → VPS public IP. The name is resolved by the client before the first packet; the server never sees it.

nginx: no — keep it out of the path. A reverse proxy routes on layer-7 identifiers, and WireGuard offers none. nginx could only blind-forward UDP via the stream module, which adds a hop and buys nothing.

They coexist without conflict anyway: nginx owns TCP 80/443, WireGuard owns UDP 51820. Different protocol, different port.

Two traps follow from this:

  • Proxying CDNs break it. On Cloudflare the record must be DNS-only (grey cloud). Orange-cloud proxying is HTTP/TCP only and silently blackholes the tunnel.
  • No TLS certificate is involved. WireGuard does its own Noise-protocol key exchange. There is nothing for certbot to do here.

2. AllowedIPs is two things at once

This is the field everyone misreads. On each peer entry it means, simultaneously:

  • Outbound (routing): "send packets for these destinations to this peer." wg-quick installs matching routes for you.
  • Inbound (access control): "from this peer, accept only packets with these source addresses." Anything else is dropped — this is the cryptokey routing table, and it is the tunnel's firewall.

So AllowedIPs is not a preference; it's the policy. A subnet that isn't listed cannot be reached and cannot be spoofed.

3. Only one side needs to be reachable

The peer with a public IP sets ListenPort and no Endpoint. The peer behind CGNAT sets Endpoint and no ListenPort — it initiates, and the reply flows back through the NAT mapping its own packet created. PersistentKeepalive = 25 keeps that mapping alive during idle periods (carrier NAT entries expire in ~30–120s).

Addressing plan

Pick a tunnel subnet that collides with nothing you use. 192.168.0.0/24 is the home LAN here, so the tunnel gets its own range:

Host Tunnel IP Role
VPS 10.10.0.1 public, listens on UDP 51820
home server 10.10.0.2 behind CGNAT, dials out
laptop 10.10.0.3 roaming
phone 10.10.0.4 roaming

Write this table down somewhere durable. Hand-assigned tunnel addresses are the one piece of state WireGuard won't manage for you.

Part 1 — the VPS

sudo apt update && sudo apt install -y wireguard

Keys

Every peer gets its own keypair. Generate each private key on the machine that will use it — a private key should never travel:

umask 077                                    # keys must not be world-readable
wg genkey | tee privatekey | wg pubkey > publickey

Only the public keys get exchanged. Optionally add a shared symmetric key per peer pair (wg genpsk) as a PresharedKey — it hardens the link against a future quantum-capable attacker recording traffic today.

/etc/wireguard/wg0.conf

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

[Peer]
# home server
PublicKey  = <HOME_SERVER_PUBLIC_KEY>
AllowedIPs = 10.10.0.2/32

[Peer]
# laptop
PublicKey  = <LAPTOP_PUBLIC_KEY>
AllowedIPs = 10.10.0.3/32
sudo chmod 600 /etc/wireguard/wg0.conf

Note the /32 on peer entries. /24 there would claim the entire tunnel subnet for one peer and break every other route.

Forwarding between peers

Peers reach each other through the VPS, so it must forward. Without this, the laptop can reach the VPS and the home server can reach the VPS, but they can't see each other — a confusing half-working state:

echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-wireguard.conf
sudo sysctl --system

Firewall

sudo ufw allow 51820/udp

The single most common failure

Open UDP 51820 in the cloud provider's security group / network ACL as well. A host firewall rule alone is not enough on AWS, GCP, Azure, Oracle, Hetzner… and a rule that says TCP looks right at a glance while dropping everything.

Start it

sudo systemctl enable --now wg-quick@wg0
sudo wg show

Part 2 — the home server (behind CGNAT)

Same install and key generation, then /etc/wireguard/wg0.conf:

[Interface]
Address    = 10.10.0.2/32
PrivateKey = <HOME_SERVER_PRIVATE_KEY>
# no ListenPort — this side always initiates

[Peer]
PublicKey           = <VPS_PUBLIC_KEY>
Endpoint            = wg.example.com:51820
AllowedIPs          = 10.10.0.0/24
PersistentKeepalive = 25

AllowedIPs = 10.10.0.0/24 (the whole tunnel subnet, not just 10.10.0.1/32) is what lets this host talk to the other peers via the VPS.

sudo systemctl enable --now wg-quick@wg0
ping -c3 10.10.0.1

Endpoint DNS is resolved once, at interface bring-up

If the VPS address ever changes, systemctl restart wg-quick@wg0 on each client. For a VPS with a static IP this never comes up; for anything dynamic, run a small re-resolve timer.

Part 3 — optional: reach the whole home LAN

The above gives you host-to-host access over tunnel IPs. To let roaming peers reach every device on the home LAN (printer, NAS, router UI), extend two things.

On the VPS, add the LAN to the home server's peer entry — this both routes it and authorises it as a source:

[Peer]
PublicKey  = <HOME_SERVER_PUBLIC_KEY>
AllowedIPs = 10.10.0.2/32, 192.168.0.0/24

Roaming peers list the same range in their peer entry for the VPS (AllowedIPs = 10.10.0.0/24, 192.168.0.0/24).

On the home server, forward and masquerade, so LAN devices don't each need a route back to the tunnel:

[Interface]
# ...
PostUp   = sysctl -w net.ipv4.ip_forward=1; iptables -t nat -A POSTROUTING -s 10.10.0.0/24 -o <lan-iface> -j MASQUERADE
PostDown = iptables -t nat -D POSTROUTING -s 10.10.0.0/24 -o <lan-iface> -j MASQUERADE

Decide this deliberately

Exposing the LAN means a compromised VPS — a rented box with a public port — becomes a foothold on your home network. If you only ever need a handful of services, skip Part 3 and reach the home server on 10.10.0.2 alone.

Full-tunnel variant

To route all client traffic out through the VPS (public-wifi protection), a roaming client uses AllowedIPs = 0.0.0.0/0, ::/0 and the VPS masquerades to its own uplink. That is a different job — an exit node, not a rendezvous point — and it puts real bandwidth through the VPS. Split tunnel is the right default for reaching your own kit.

The thing I didn't expect: the VPS may not receive UDP at all

Everything above assumes packets reach the VPS. Cheap VPS hosts — the ones where the public address is tunnelled in from an upstream edge rather than routed to the VM natively — quite often filter inbound UDP, passing only a short allowlist. Nothing in the panel says so.

The tell is a giveaway once you know it: run ip -4 addr on the VPS and look at the MTU of the main interface. 1500 means native. Anything less — 1476, 1480 — means your traffic is encapsulated upstream, and there is a device in front of you with opinions about what it forwards.

The symptom is a tunnel that looks perfect and does nothing: correct keys, endpoint resolving to the right address, transfer climbing on the sending side, and 0 B received forever.

Proving where packets die

tcpdump taps traffic before iptables, ufw, or anything you configured. So it settles the question outright:

sudo tcpdump -ni any udp port 51820
  • Packets appear → they reach the machine; a local rule is dropping them. Yours to fix.
  • 0 packets captured → they never arrive. Nothing you do on that box matters. Look upstream.

Do this before touching a single firewall rule. I spent an hour auditing iptables chains that turned out to be irrelevant, because I assumed the drop was mine.

Finding a port that does get through

If the edge filters by port, you need to learn which ones survive — and guessing one at a time is slow. Spray a set of candidates from the client while capturing on the VPS.

From the client, repeatedly, so you don't have to coordinate timing:

import socket, time
ports = [443, 80, 1194, 500, 4500, 1701, 3478, 8080, 51820]
end = time.time() + 180
while time.time() < end:
    for p in ports:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        try: s.sendto(b"PROBE%05d" % p, ("<vps-ip>", p))
        except Exception: pass
        s.close()
    time.sleep(2)

On the VPS, watch what lands:

sudo tcpdump -ni any 'udp and not port 22 and not port 53'

Read the destination port on each captured line — those are the ports that made it. Expect the capture to be noisy: on a shared layer-2 segment you'll also see other tenants' mDNS and NetBIOS chatter. Your probes are identifiable by their source address and their regular 2-second spacing.

Pick a survivor, set it as ListenPort on the VPS and in the client's Endpoint, open it in ufw, and restart both ends. A port number is just a label — WireGuard behaves identically on any of them. Two caveats when you take over a well-known port:

  • Check nothing already listens there (sudo ss -lunp | grep ':<port>'). Colliding with a real service is a worse problem than the one you're solving.
  • Expect background scan traffic aimed at whatever normally lives on that port. WireGuard silently discards anything that doesn't authenticate — it is famously quiet to unauthenticated probes — so this is noise rather than exposure.

If nothing gets through, the edge is dropping UDP wholesale. Ask the provider to open a port before building around it; failing that, see the TCP fallback below.

While you're at it: check the MTU

That same upstream encapsulation caps how large a packet can be. wg-quick sets the tunnel MTU from the local route, so the VPS end gets it right automatically while the client end happily picks 1420 — larger than the path can carry.

The result is the worst kind of bug: handshakes fine, ping fine, small requests fine, and then anything bulky hangs — ssh freezes on its first long output, a file transfer stalls at a few hundred KB. Set MTU explicitly on the client to match the VPS's tunnel MTU, and verify with a maximum-size packet that forbids fragmentation:

ping -c2 -M do -s $((MTU - 28)) 10.10.0.1      # must succeed
ping -c1 -M do -s $((MTU - 27)) 10.10.0.1      # must fail

Both results matter. The first proves full-size packets survive the path; the second proves you're testing the real limit rather than a smaller one.

Verifying

sudo wg show

Read three fields on each peer:

  • latest handshake — the only real proof. Under ~2 minutes means the tunnel is live. Blank means it has never connected.
  • transfer — non-zero in both directions. Sent-only is the classic "packets leave, nothing comes back" signature: a firewall or AllowedIPs problem.
  • endpoint on the VPS — the home server's observed public address, which confirms it dialled in.

Troubleshooting

Symptom Cause
No handshake, ever UDP 51820 blocked. tcpdump on the server settles whether packets even arrive — check that before auditing firewall rules. Then: cloud security group, ufw, provider-side UDP filtering.
Handshake fine, big transfers hang MTU too large for the path. Pin it on the client.
Handshake OK, no traffic AllowedIPs doesn't cover the addresses you're using. It must be right on both peers.
Works, then dies when idle Missing PersistentKeepalive = 25 on the CGNAT side.
Two peers can't see each other ip_forward not enabled on the VPS.
Works at home, not on some networks That network blocks outbound UDP. See below.
Key is not the correct length A private key was pasted where a public one belongs, or a newline crept in.

When UDP itself is blocked

Some corporate and hotel networks allow only TCP 443. Plain WireGuard cannot get out. The fix is to wrap it — wstunnel or udp2raw — and that wrapper, being TLS on 443, can be fronted by nginx's stream module with SNI routing. It's the one case where a reverse proxy enters the picture. Treat it as a fallback to add when you hit the problem, not part of the initial design.

Adding a peer later

  1. Generate a keypair on the new device.
  2. Add a [Peer] block to the VPS config with its public key and a fresh /32.
  3. sudo systemctl reload wg-quick@wg0 on the VPS — reload re-reads peers without dropping existing tunnels; restart would bounce everyone.
  4. On the device: Endpoint, the VPS public key, AllowedIPs, and PersistentKeepalive if it sits behind NAT.

For phones, qrencode -t ansiutf8 < client.conf renders a config the WireGuard app scans directly — but note that generating the config centrally means the private key was created off-device. Acceptable for a phone; not for a server.