WireGuard Through CG-NAT
Most home internet connections in 2026 sit behind carrier-grade NAT. Your ISP assigns you a private address in the 100.64.0.0/10 range, routes your traffic through a shared public IP, and you have no way to receive inbound connections. For a homelab this is a serious problem. You cannot expose services, you cannot establish a WireGuard endpoint that peers can reach, and you cannot SSH into your own infrastructure from outside.
The standard solution is a VPS with a public IP acting as a relay. Your home network connects out to the VPS, the VPS terminates inbound connections, traffic flows both ways through the established tunnel. This works and it is what I run. But there is a second problem that this setup does not solve on its own.
Some networks — corporate networks, airport WiFi, certain mobile carriers — block or throttle UDP traffic. WireGuard is UDP-only. On a network that drops non-DNS UDP, your tunnel does not come up.
The fix is to wrap WireGuard inside a WebSocket connection using wstunnel. WebSocket is TCP over port 443. Nothing blocks it. From the network's perspective you are doing HTTPS.
This post documents the full setup: wstunnel on a Strato VPS, WireGuard behind it, a Raspberry Pi as the LAN gateway for reverse access, and the wg-easy config clobbering problem that took longer to debug than it should have.
Architecture
[Laptop / Phone]
|
HTTPS:443 (WebSocket)
|
[Strato VPS — sync.c0xl.ch]
wstunnel (listens :443, forwards to :51820)
WireGuard (listens :51820, peers connect through wstunnel)
|
WireGuard tunnel
|
[rpi1 — 192.168.1.130]
WireGuard peer (permanent outbound connection to VPS)
Acts as LAN gateway for the tunnel
|
192.168.1.0/24 (home LAN)
The Raspberry Pi maintains a permanent outbound WireGuard connection to the VPS. Because the Pi initiates the connection, CG-NAT is not a problem — the outbound connection punches through. Once that tunnel is up, the VPS can route traffic back into the home network through the Pi. Remote clients connect to the VPS, get routed to the Pi, and from there reach anything on the home LAN.
VPS Setup
Install wstunnel on the VPS. It is a single binary, available from the GitHub releases page.
Create a systemd service that starts wstunnel before WireGuard:
[Unit]
Description=wstunnel WebSocket to WireGuard
After=network.target
Before=wg-quick@wg0.service
[Service]
ExecStart=/usr/local/bin/wstunnel server \
--restrict-to 127.0.0.1:51820 \
wss://0.0.0.0:443
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
--restrict-to 127.0.0.1:51820 is important. It limits where wstunnel will forward traffic — only to the local WireGuard port. Without this, wstunnel becomes an open proxy.
WireGuard on the VPS listens on 127.0.0.1:51820 rather than 0.0.0.0:51820. External clients never reach WireGuard directly. Everything goes through wstunnel.
[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = <vps-private-key>
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
[Peer]
# rpi1 — permanent LAN gateway peer
PublicKey = <rpi-public-key>
AllowedIPs = 10.10.0.2/32, 192.168.1.0/24
The AllowedIPs for the Pi peer includes the entire home LAN subnet. This tells WireGuard that traffic destined for 192.168.1.0/24 should be routed through the Pi's tunnel endpoint.
Client Setup
On a laptop or phone, the WireGuard client config points to the wstunnel endpoint rather than WireGuard directly. But standard WireGuard clients speak WireGuard protocol, not WebSocket — so the client also runs wstunnel locally, listening on a local UDP port and forwarding to the VPS WebSocket endpoint.
For desktop clients, add a wstunnel process that starts with WireGuard:
wstunnel client \
--local-to-remote "udp://127.0.0.1:51820?timeout_sec=0" \
wss://sync.c0xl.ch:443
The WireGuard client then points its endpoint to 127.0.0.1:51820 instead of the VPS IP directly. The chain is: WireGuard client → local wstunnel → WebSocket to VPS → VPS wstunnel → WireGuard on VPS.
Split tunnel AllowedIPs — only route home LAN and VPN subnet through the tunnel, not all traffic:
[Peer]
PublicKey = <vps-public-key>
Endpoint = 127.0.0.1:51820
AllowedIPs = 10.10.0.0/24, 192.168.1.0/24
PersistentKeepalive = 25
The wg-easy Config Clobbering Problem
If you manage the VPS WireGuard config with wg-easy — the Docker-based web UI — you will hit a problem the moment you add custom PostUp/PostDown rules or non-standard peer configurations.
wg-easy rewrites wg0.conf from its own internal state every time a peer is added, removed, or the config is saved through the UI. Any manual edits to the file are overwritten silently.
The fix is a post-start.sh script that runs after wg-easy has written its config and before WireGuard actually starts the interface. Mount it into the wg-easy container and point the container's PostUp at it:
#!/bin/bash
# post-start.sh — runs after wg-easy writes config, before wg-quick up
# Add the home LAN route for rpi1 peer
# wg-easy strips this from the peer AllowedIPs on save
ip route add 192.168.1.0/24 via 10.10.0.2 dev wg0 2>/dev/null || true
In the wg-easy Docker compose environment variable:
environment:
- WG_POST_UP=bash /etc/wireguard/post-start.sh
This is fragile by design — you are patching over a tool that does not respect manual configuration. The cleaner long-term solution is to drop wg-easy entirely and manage WireGuard directly with wg-quick and a config under version control. wg-easy is convenient for adding mobile peers quickly. For anything infrastructure-critical, it is the wrong tool.
The Raspberry Pi as LAN Gateway
The Pi runs a standard WireGuard peer config with the VPS as its endpoint. Because it connects out through the normal home internet connection, CG-NAT does not interfere. PersistentKeepalive keeps the tunnel alive through NAT state timeouts:
[Interface]
Address = 10.10.0.2/24
PrivateKey = <rpi-private-key>
[Peer]
PublicKey = <vps-public-key>
Endpoint = sync.c0xl.ch:443
AllowedIPs = 10.10.0.1/32
PersistentKeepalive = 25
Note the Endpoint points to port 443, not 51820. The Pi also runs wstunnel client-side, same as a laptop would. The tunnel runs over WebSocket even for the permanent LAN gateway connection.
IP forwarding must be enabled on the Pi:
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
sysctl -p
Without this the Pi accepts the tunneled traffic but does not forward it onto the LAN.
Result
From anywhere in the world, on any network including ones that block UDP, a WireGuard connection to sync.c0xl.ch brings up a tunnel into the home LAN. The connection traverses CG-NAT on the home side and WebSocket obfuscation on the client side. From the restrictive network's perspective it is HTTPS traffic to port 443.
The setup has been running on two Strato VPS instances without issues. Latency overhead from the WebSocket wrapping is negligible — single digit milliseconds compared to a direct WireGuard connection on the same path.
The wg-easy clobbering issue is the only real operational annoyance and the post-start.sh workaround handles it until I migrate the VPS WireGuard config to plain wg-quick management.