How WireGuard keepalives keep a stale conntrack blackhole alive
Notes. From a live debugging session on a containerized WireGuard server (wg-easy) behind a Linux host’s NAT/conntrack. The keepalive twist appears undocumented in public sources. No warranty; corrections welcome.
At 10,000 feet
Setting: a WireGuard VPN server in a container on a Linux host that does connection tracking (conntrack) on the WireGuard UDP port. Peers use persistent keepalives to hold the tunnel open through NAT.
Problem: when the server container restarts, a stale conntrack entry for its UDP port survives on the host and silently blackholes the tunnel - peer packets keep matching the dead entry instead of reaching the new server.
What this note establishes: why it never self-heals. The stale entry would normally age out, but every peer keepalive refreshes the dead entry’s timer - your own keepalives keep the blackhole alive indefinitely.
Takeaway: flush the matching conntrack entries on every WireGuard server start. A tiny systemd unit does it.
The fix (VERIFIED FIRING)
A systemd unit flushes the conntrack entries for the WireGuard UDP port (scoped by protocol + port) on each server start; the next inbound packet then builds a fresh entry. On a live restart the unit fired and the tunnel came back without manual intervention.
Prior art, and what’s actually new
- Known, credit it: “restart a UDP service and a stale conntrack entry blackholes its port until flushed” - Kubernetes UDP/portmap reports, OpenWrt WireGuard threads - along with the flush-on-restart fix.
- New (undocumented): persistent-keepalive traffic keeps the dead entry perpetually fresh so it never self-heals, turning a transient glitch into a permanent outage - hence the flush-on-start unit is necessary, not optional.
Honesty
- Measured in-session: the blackhole after restart, and the flush fixing it.
- Originally inferred, since instrumented: the keepalive-refreshes-the-timer step - measured packet-by-packet below.
Validation (2026-08-31)
Reproduced in an isolated throwaway environment: a privileged Docker container (own netns + conntrack tables) holding three inner network namespaces:
client (192.0.2.2) --- router/"host" (192.0.2.1 | 198.51.100.1) --- server/"container" (198.51.100.10)
The router does Docker-style DNAT of UDP 51820 to the server; the client uses
persistent-keepalive 5. Kernel 6.1, wireguard-tools v1.0.20210914,
conntrack v1.4.7, nf_conntrack_udp_timeout_stream=120.
- Restart blackhole (REPRODUCED, one nuance): recreated the server
Docker-style - same listen port, new IP (.10 → .11), DNAT rule updated. The
conntrack entry still held the old NAT decision
(
reply-src=198.51.100.10), so packets were DNATed to the dead .10; ping failed 60+ s (past WireGuard’s ~15 s re-handshake) until flushed. Nuance: a plain down/up with the same IP and port self-healed in 23 s - the blackhole requires the entry to pin outdated NAT state (the wg-easy/Docker case). - Keepalives pin the dead entry (REPRODUCED): sampled the stale entry’s timeout every 12 s while blackholed: 117, 117, 118, 119 of 120 - bumped back by each packet (keepalives, then handshake retransmits every ~5 s; same UDP tuple), where an unrefreshed entry would have lost ~36 s. Negative control: with the client’s WireGuard stopped, the entry decayed linearly, 119 → 104 → 89 over 30 s (−1/s). Absent keepalives the blackhole clears in ≤2 minutes; with them it is permanent.
- Flush fixes it (REPRODUCED):
conntrack -D -p udp --dport 51820in the router netns; tunnel recovered in ~3 s with a fresh entry to .11,wg showconfirming a new handshake.
Verdict: all steps reproduced end-to-end. Namespaces and container destroyed after the run.
Licensed under the site footer’s CC BY 4.0. If this saved you a debugging session, the optional thanks link in the footer is appreciated, no obligation.