WireGuard in Containers
WireGuard in Containers
Section titled “WireGuard in Containers”Running WireGuard inside Docker or Podman is straightforward for a single tunnel, and gets sharp edges fast for anything beyond that — site-to-site, multi-hop chains, or a container as a transparent gateway. This page collects the field-tested gotchas so you don’t rediscover them at 2 AM.
For a build-log applying these patterns to a six-hop multi-country chain, see the Geographic Labyrinth series on Field Notes.
The minimum-viable container
Section titled “The minimum-viable container”WireGuard needs three things from the kernel that unprivileged containers don’t get by default:
NET_ADMINcapability — to bring up thewg0interface, set peers, and modify routes.- IP forwarding enabled —
net.ipv4.ip_forward=1if the container is forwarding for anything beyond itself. - The kernel WireGuard module loaded on the host —
modprobe wireguard. Containers can’t load kernel modules.
In docker-compose.yml:
services: wg: # Always pin a specific tag, not :latest — linuxserver images update # frequently and an unpinned rebuild has broken peers mid-session. image: linuxserver/wireguard:1.0.20240924-ls110 cap_add: - NET_ADMIN sysctls: net.ipv4.ip_forward: 1 net.ipv4.conf.all.src_valid_mark: 1 volumes: - ./config:/config - /lib/modules:/lib/modules:rosrc_valid_mark is required by WireGuard’s cryptokey-routing mark check. Forget it and the handshake completes but no traffic flows.
Don’t write to /proc/sys from the entrypoint
Section titled “Don’t write to /proc/sys from the entrypoint”A common pattern in older WireGuard container images is an entrypoint that runs echo 1 > /proc/sys/net/ipv4/ip_forward. This fails inside an unprivileged container because /proc/sys is mounted read-only:
entrypoint.sh: line 45: can't create /proc/sys/net/ipv4/ip_forward: Read-only file systemThe fix is the sysctls: block above. The container runtime sets the value via the host’s mount of the namespace’s sysctl before the entrypoint runs. Drop the echo line entirely once that’s in place.
privileged: true also works but grants the container root-equivalent access to the host. Don’t reach for it unless you’ve ruled out the targeted capability approach.
Running on macOS via Docker Desktop
Section titled “Running on macOS via Docker Desktop”Docker Desktop on macOS runs containers inside a LinuxKit VM with a real recent Linux kernel (6.12+), so the WireGuard module is built in and every container behaves like a regular Linux container. The minimum-viable container above and the entire multi-hop chain work, with four specific differences. (Verified June 2026 on Docker Desktop 29.5 / LinuxKit 6.12.76 / aarch64.)
1. All sysctls must live in the compose sysctls: block, never in PreUp. /proc/sys is read-only inside an unprivileged container, so any runtime sysctl -w from a PreUp/PostUp script fails with Read-only file system. Compose’s sysctls: block sets them at container-creation time before the read-only mount applies:
sysctls: net.ipv4.ip_forward: "1" net.ipv4.conf.all.src_valid_mark: "1" net.ipv4.conf.all.rp_filter: "2" net.ipv4.conf.default.rp_filter: "2"rp_filter on all covers eth0 because the kernel uses MAX of all/<iface> when validating. Per-interface settings on interfaces that don’t exist yet at container start (like wg0) won’t work — those have to wait until PostUp, which runs after ip link add so the interface exists.
2. Add Table = off to any [Interface] whose peer has AllowedIPs = 0.0.0.0/0. wg-quick interprets a catch-all AllowedIPs as “you want all traffic through wg0” and sets up its own fwmark-based default routing — which includes a sysctl -q net.ipv4.conf.all.src_valid_mark=1 that fails against read-only /proc/sys, restart-looping the container. Table = off disables wg-quick’s auto-routing so you manage the default route yourself in PostUp.
3. Use ip route add default dev wg0 (no via) on chain hops. When each hop has its own 10.0.N.0/24, the next hop’s wg-side IP (e.g. 10.0.3.1 from a container on 10.0.2.0/24) isn’t on a directly reachable subnet, so ip route add default via 10.0.3.1 dev wg0 fails with Nexthop has invalid gateway. Drop the via — ip route add default dev wg0 metric 50 is a device route, and WireGuard’s cryptokey routing picks the peer based on AllowedIPs. The Linux version using via <next-wg-IP> works on Linux when chain hops share a subnet, but dev-only is more robust everywhere and the recommended form on macOS.
4. ICMP from inside containers doesn’t transit Docker Desktop’s network shim to the real internet (gvisor-style userspace proxy). traceroute and ping work inside the bridge — they’ll show your chain’s wg-side hops correctly — but stop with * * * after the bridge gateway. Don’t use ICMP-based tests for end-to-end proof. Use TCP: docker exec primary curl https://api.ipify.org works fine, and docker exec exit tcpdump -i eth0 'tcp port 443' lets you confirm the SYN is sourced from the exit container’s bridge IP (not the entry’s), which is the gold-standard proof the chain actually transited.
5. Mac wg client → first container Endpoint cannot be 127.0.0.1. If you want the Mac itself to act as a chain hop (using brew install wireguard-tools + wg-quick up on the Mac), and the first container publishes its UDP port to Docker Desktop, the obvious choice — Endpoint = 127.0.0.1:<port> in the Mac config — fails silently. wg-quick on macOS auto-adds route -q -n add -inet <endpoint-ip> -gateway <default-gw> to keep its own UDP out of the tunnel. For a 127.0.0.1 endpoint that route redirects loopback packets to the LAN gateway, which has no way to deliver them back. No handshake, no errors.
Fix: use the Mac’s actual LAN IP as the Endpoint, and bind the Docker-published port to the same IP (ports: ["${MAC_LAN_IP}:51820:51820/udp"]). The bypass route then targets the LAN IP, which still hairpins to the local stack correctly (via lo0 in macOS routing). Reference implementation at dotfiles/macos/wg-labyrinth — a working 3-hop chain with Mac as entry — and a distilled config-template + gotcha-catalog at snippet #6 for readers who want a copy-paste reference without cloning.
What still works identically: NET_ADMIN capability, /dev/net/tun device mapping, IPAM static IPs, all the iptables rules for FORWARD/MASQUERADE, the Pro Custodibus per-hop policy-routing pattern, MTU stacking. A six-hop chain end-to-end test on this stack returned the same WAN IP as a direct Mac→internet curl, with mss 1380 confirming the expected 5-hop MTU floor.
Reverse-path filter drops chain traffic
Section titled “Reverse-path filter drops chain traffic”rp_filter (reverse-path filter) does a sanity check on inbound packets. In strict mode (1, the kernel default on most distros) it asks: “would I have used this same interface to send a reply to this source?” — and silently drops the packet if not. Multi-hop chains, site-to-site routers, and transparent gateways all create routinely asymmetric paths by design, so strict mode drops their traffic on the floor while wg show keeps reporting healthy handshakes.
Set loose mode (2) — which still rejects unroutable source addresses but doesn’t require interface symmetry:
sysctls: net.ipv4.conf.all.rp_filter: 2 net.ipv4.conf.default.rp_filter: 22 and not 0 for a non-obvious reason: the kernel evaluates MAX(.all, per-interface) when source-validating (kernel doc: ip-sysctl.txt). On a host whose distro defaults eth0 to 1, setting .all = 0 resolves to MAX(0, 1) = 1 → still strict → still drops; setting .all = 2 resolves to MAX(2, 1) = 2 → loose → flows. (Docker Desktop’s LinuxKit happens to default per-interface to 0, so .all = 0 works there incidentally — but only there. Use 2 for portability.)
The .default = 2 line matters: newly-created interfaces (including wg0 after ip link add) inherit rp_filter from .default at creation. Combined with MAX(.all, wg0) = 2, no PostUp sysctl is needed for wg0 — and trying one anyway will fail with Read-only file system inside the container (/proc/sys is read-only at runtime).
Set on every container in a chain, not just the entry. If you suspect rp_filter is still the culprit, watch nstat -az for IpInAddrErrors or TcpExtListenDrops climbing while traffic is in flight.
NAT for serving traffic out a container
Section titled “NAT for serving traffic out a container”A WG container that forwards traffic from peers to the wider internet (or to another container) needs SNAT (Source Network Address Translation — rewrites the source address) or MASQUERADE (SNAT to the egress interface’s current IP, useful when that IP is dynamic) on the egress interface. The Pro Custodibus pattern using marks is cleaner than a blanket MASQUERADE because it scopes the rewrite to peer-originated traffic:
iptables -t mangle -A PREROUTING -i wg0 -j MARK --set-mark 0x30iptables -t nat -A POSTROUTING ! -o wg0 -m mark --mark 0x30 -j MASQUERADEThis marks anything arriving on wg0 and only MASQUERADEs marked traffic on non-wg0 egress. Return packets find their way back via conntrack.
Multi-hop chains: the birthplace-socket trick
Section titled “Multi-hop chains: the birthplace-socket trick”The fundamental property that makes WireGuard chains tractable, documented at wireguard.com/netns:
A WireGuard interface’s UDP socket remains bound to the namespace the interface was created in, even after
ip link set wg0 netns <other>.
This means you can create wg1 inside namespace A, move it to namespace B, and its encrypted UDP will naturally exit through namespace A’s network. Stack this pattern N deep and you have an onion: namespace 3’s wg2 emits UDP into namespace 2, namespace 2’s wg1 emits UDP into namespace 1, namespace 1’s wg0 emits UDP onto the real wire.
No veth pairs between hops. No policy routing on the intermediate hops. The kernel does the work.
Per-hop routing tables (Pro Custodibus pattern)
Section titled “Per-hop routing tables (Pro Custodibus pattern)”When an intermediate hop also serves local traffic (not just forwarding for the chain), the chain’s forwarded traffic and the hop’s own tunnel-handshake traffic need to take different paths. The clean separation:
[Interface]Table = 123PreUp = ip rule add iif wg0 table 123 priority 456PostDown = ip rule del iif wg0 table 123 priority 456ip rule add iif wg0 only matches forwarded traffic arriving on wg0. Locally-generated handshake UDP doesn’t have an iif, so it uses the main routing table and reaches its peer normally.
Asymmetric AllowedIPs is mandatory — and the trap is forgetting 0.0.0.0/0
Section titled “Asymmetric AllowedIPs is mandatory — and the trap is forgetting 0.0.0.0/0”AllowedIPs is not a route. It is a cryptokey-routing filter: WireGuard will only encrypt a packet for a given peer if the packet’s destination matches that peer’s AllowedIPs. Anything that doesn’t match any peer’s AllowedIPs falls back to the kernel’s normal routing table — which inside a Docker container is the bridge default route, leaking the packet in the clear to the host network.
For a chain client → A → B → C → D → exit → internet, on each middle hop (say B):
- The peer toward the client (A):
AllowedIPs = 10.0.A.0/24— narrow, just the upstream tunnel subnet so return packets find their way back. - The peer toward the internet (C):
AllowedIPs = 0.0.0.0/0— catch-all, so any destination that doesn’t match the narrow client-side prefix flows forward.
WireGuard does longest-prefix match across all peers on the interface, so the /24 on the client side wins for return traffic and the 0.0.0.0/0 on the internet side wins for everything else. No loop, no leak.
The bug to watch for: listing only the chain’s 10.0.x.0/24 subnets in AllowedIPs on every peer and never including 0.0.0.0/0. The handshake comes up, wg show looks healthy, and every packet to a real internet address fails the cryptokey filter, falls through to the Docker bridge default, and goes out the host’s WAN in the clear. There is no error message and tcpdump on wg0 shows no traffic — just absence. See Field Notes: Geographic Labyrinth Part 2 for the pcap forensics.
The exit hop is the one exception to the catch-all rule: its only peer is the upstream chain member, with AllowedIPs covering all upstream tunnel /24s but not 0.0.0.0/0. Putting the catch-all on the exit’s chain peer would loop every internet response back into the tunnel instead of out eth0.
Default route override is your responsibility (and the routing-loop trap)
Section titled “Default route override is your responsibility (and the routing-loop trap)”wg-quick does not override the container’s default route unless you put AllowedIPs = 0.0.0.0/0 on a peer and the Table directive is left at its default. With Table = off (which you need on Docker Desktop, per macOS section above), the override is entirely up to you. The naïve approach has two subtle failure modes.
The naïve approach — and why it’s wrong:
PostUp = ip route add default dev %i metric 50The Docker bridge default route already exists at metric 0 (the kernel’s default for ip route add without an explicit metric). ip route add default dev wg0 metric 50 adds a second default at metric 50, never replaces the first, and the kernel always prefers the lower metric. Your traffic continues out eth0; the chain is bypassed; wg show keeps reporting healthy handshakes because the control plane works fine.
The fix — use replace with no explicit metric so both default to 0 and the replace actually replaces:
PostUp = ip route replace default dev %iThe other trap — once middle’s main-table default IS dev wg0, middle’s own WireGuard handshake replies (UDP packets back to upstream peers via the Docker bridge) ALSO take that default → enter cryptokey routing → match the downstream peer’s 0.0.0.0/0 → get re-encrypted into the chain instead of going back to the upstream peer. The upstream peer never sees the handshake response, never establishes, and traffic from there never enters the chain.
wg-quick’s Table = auto solves this with fwmark: WireGuard tags its own outbound UDP with a mark, and a routing rule sends marked traffic through the main table while unmarked traffic uses a custom table with default dev wg0. But Table = auto ALSO runs sysctl -q net.ipv4.conf.all.src_valid_mark=1 against the read-only /proc/sys, which restart-loops the container on Docker Desktop. The wiki’s Table = off workaround disables BOTH the broken sysctl and the fwmark routing — you have to put the fwmark logic back in manually:
[Interface]Table = offPostUp = wg set %i fwmark 51820PostUp = ip rule add not fwmark 51820 table 51820PostUp = ip rule add table main suppress_prefixlength 0PostUp = ip route add default dev %i table 51820This is what Table = auto does internally, minus the doomed sysctl call. WG-emitted UDP is marked → main table → eth0 (handshake responses go back to upstream). Unmarked decapsulated forwarded traffic → custom table → wg0 (chain forwarding). No loop.
Other common traps:
- Wrong gateway address. It’s common to confuse
${NEXT_HOP}(“the next container’s Docker bridge IP”, e.g.172.20.0.30) with the actual gateway you need (“the next container’s wg-side IP”, e.g.10.0.3.1).wg0has no path to a 172.20.x.x address — the route is silently inert. Name your variablesNEXT_HOP_BRIDGE_IPandNEXT_HOP_WG_IPfrom day one. \-continuation in PostUp doesn’t work.wg-quickreads its config file line by line and does NOT join lines ending in\. A PostUp written ascmd1 ; \\+ indentedcmd2becomes a bogus key on the second line thatwg setconfrejects (Line unrecognized: 'cmd2;\\') and the interface fails to come up. Use multiplePostUp = ...lines instead — they run in order. (The wiki’s earlier\\examples in this section have been corrected.)
The exit container is the exception again: it should keep its Docker bridge default, because that’s the path to the real internet. Just add iptables -A FORWARD -i wg0 -o eth0 -j ACCEPT and a MASQUERADE for the chain’s 10.0.0.0/8. No fwmark routing needed on the exit because its main-table default already points at eth0.
Diagnostic recipe: is the chain actually carrying packets?
Section titled “Diagnostic recipe: is the chain actually carrying packets?”The fastest way to catch the silent-bypass mode where handshakes are green but no traffic transits the tunnels:
# 1. Traceroute from the entry container -- hop 1 should be the next wg-side IPdocker exec <entry-container> traceroute -n -m 8 1.1.1.1# Good: 1 10.0.2.1 ← next-hop wg IP# Bad: 1 172.20.0.1 ← Docker bridge (chain bypassed)
# 2. Capture on exit's eth0 while curling from entry. Source IP confirms transit.docker exec <exit-container> tcpdump -i eth0 -nn -c 5 'tcp port 443' &docker exec <entry-container> curl -s https://api.ipify.org# Good: SYN src = exit's bridge IP (e.g. 172.20.0.60)# Bad: SYN src = entry's bridge IP (e.g. 172.20.0.10)
# 3. Inspect reply TTLs in the exit's pcap. If TTLs are typical-real-internet# (e.g. 113 from Google's edge), the chain was bypassed. If they're 5-hop# less than that (e.g. 108), the chain transited.
# 4. "wg show" is necessary but not sufficient. Recent handshakes prove the# control plane works; they prove nothing about the data plane.The [SUCCESS] exit code from traceroute only means the command itself terminated cleanly. It does not mean the path went through the chain. Always assert on the actual hops.
MTU stacking
Section titled “MTU stacking”MTU (Maximum Transmission Unit — the largest packet the link can carry without fragmenting) drops at every hop because each WireGuard tunnel adds 60 bytes of overhead (20 IP + 8 UDP + 32 WG header). Starting from Ethernet 1500:
| Hops | Safe inner MTU |
|---|---|
| 1 | 1440 |
| 2 | 1380 |
| 3 | 1320 |
| 4 | 1260 |
| 5 | 1200 |
Below 1280 you break IPv6 minimum-MTU guarantees. With PPPoE (which itself drops 8 bytes), shift each row down 8 more.
The standard mitigation is MSS (Maximum Segment Size — TCP’s analog to MTU) clamping on the FORWARD chain at every container boundary, not just the outermost:
iptables -t mangle -A FORWARD -o wgN -p tcp --tcp-flags SYN,RST SYN \ -j TCPMSS --clamp-mss-to-pmtuWithout this, large TCP flows hit PMTU blackholes on real-world internet paths (many CDNs drop ICMP Frag Needed).
Related
Section titled “Related”- Tailscale — managed-mesh WireGuard with key distribution handled for you
- Tailscale Exit Node — single-hop egress through a node you control
- VPN Recommendation — comparison with commercial VPNs and where self-hosted chains fit
References
Section titled “References”- Runnable reference (macOS):
dotfiles/macos/wg-labyrinth— 3-hop chain with Mac as entry, cleanup/down/selectivitylifecycle, every gotcha on this page encoded operationally. - Snippet companion: snippet #6 — WireGuard chain on macOS, config templates + gotcha catalog — copy-paste configs + diagnostic recipe, stable URL, no clone needed.
- WireGuard official: Routing & Network Namespace Integration
- Pro Custodibus: Multi-Hop WireGuard
- dadevel/wg-netns — declarative wg-quick + network namespaces
- JeWe37/wireguard-onion — minimal 3-hop onion PoC
- Mullvad multi-hop architecture