WireGuard is brilliant right up until it isn’t. The initial setup is almost suspiciously easy — generate keys, swap public keys and endpoints, drop in an AllowedIPs, and you have a tunnel. Then you move your laptop to a coffee shop, or you start copying a large file, or you notice your phone battery draining faster than usual, and the magic starts to crack.
Most guides stop at the basics. This one starts where they stop.
Official WireGuard tools and source are on GitHub: github.com/WireGuard/wireguard-tools.
PersistentKeepalive: What It Does and When to Actually Use It
WireGuard is intentionally silent. If there’s no traffic, it sends nothing. This is a feature — it makes it almost invisible to traffic analysis and burns zero bandwidth when idle. It’s also the first thing that bites people when their tunnel "mysteriously" stops working.
The actual problem is NAT, not WireGuard.
When a client behind a NAT router sends a UDP packet to your server, the router creates a mapping: (client_private_ip:port) ↔ (router_public_ip:ephemeral_port). That mapping has a timeout. On residential routers it’s often 30–120 seconds for UDP. Once it expires, incoming packets from your server to the old mapping get dropped. The client can still initiate new traffic (which creates a fresh mapping), but the server can no longer reach the client unless the client sends first.
This is precisely the failure mode where PersistentKeepalive helps. It tells WireGuard to send a keepalive packet to the peer every N seconds, refreshing the NAT mapping before it can expire.
# /etc/wireguard/wg0.conf — on the CLIENT behind NAT
[Interface]
PrivateKey = <client-private-key>
Address = 10.0.0.2/24
DNS = 10.0.0.1
[Peer]
PublicKey = <server-public-key>
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25 # seconds; 25 is the WireGuard project's own recommendation
Why 25 seconds? It’s the value the WireGuard whitepaper uses as an example, and it’s short enough to beat most NAT timers while not being so aggressive it wastes meaningful bandwidth. Each keepalive is a single small UDP packet — maybe 32 bytes of overhead. Even at 25s intervals that’s roughly 1 KB/min. Not a concern on broadband; relevant if you’re on a metered mobile connection.
Gotcha #1: Don’t Put It on Both Sides
A common mistake is adding PersistentKeepalive to every peer config on every machine "just to be safe." If your server has a public IP and is directly reachable (no NAT), it doesn’t need keepalives. Adding them there means both sides are pinging each other constantly, doubling unnecessary traffic and making debugging harder. Put it only on the side that’s behind NAT.
Gotcha #2: It Doesn’t Fix Broken Routing
If your tunnel is down because of a misconfigured AllowedIPs, a firewall rule that blocks UDP 51820, or a stale key — keepalives won’t help. WireGuard will happily send keepalive packets into a void and the tunnel will remain dead. Before reaching for PersistentKeepalive, verify the actual handshake with wg show wg0 and look at the latest handshake timestamp. If it’s more than 3 minutes old, you have a connectivity problem, not a NAT problem.
# Check handshake age and peer status
sudo wg show wg0
# Output you want to see:
# peer: <pubkey>
# endpoint: 203.0.113.5:51820
# allowed ips: 0.0.0.0/0
# latest handshake: 14 seconds ago ← healthy
# transfer: 1.23 MiB received, 456 KiB sent
Gotcha #3: Mobile Battery Impact
On Android and iOS, PersistentKeepalive wakes up the radio periodically, which prevents the device from entering deep sleep. The WireGuard mobile apps handle this gracefully by default (they use the platform’s "on-demand" activation), but if you’re running a custom config and wondering why your phone battery is 15% worse — this is likely the culprit. For mobile, consider using a longer interval (60–120s) if your NAT router is tolerant, or rely on on-demand VPN activation instead.
MTU Tuning: The Silent Killer of WireGuard Performance
MTU issues don’t break WireGuard. They just make it subtly horrible in ways that are maddeningly hard to diagnose. Symptoms: SSH works fine, but large file transfers stall. Websites load partially. Video calls drop every few minutes. Your ping to 8.8.8.8 is perfect but curl https://... times out.
Welcome to PMTUD black holes.
The Math You Need to Know
WireGuard encapsulates packets inside UDP over IP. Each encapsulated packet gains overhead:
| Outer protocol | IP header | UDP header | WireGuard header | Total overhead |
|---|---|---|---|---|
| IPv4 | 20 bytes | 8 bytes | 32 bytes | 60 bytes |
| IPv6 | 40 bytes | 8 bytes | 32 bytes | 80 bytes |
Standard Ethernet MTU is 1500 bytes. To avoid fragmentation, your WireGuard interface MTU must be:
WireGuard MTU = Physical MTU − WireGuard overhead
For a standard ISP connection over IPv4:
1500 − 60 = 1440 bytes
For a standard ISP connection over IPv6:
1500 − 80 = 1420 bytes
The WireGuard kernel module defaults to 1420, which covers the IPv6 case. For most people on IPv4-only uplinks, 1420 leaves 20 bytes of headroom on the table — not catastrophic, just slightly suboptimal.
Where It Actually Gets Complicated
The formula above assumes your physical link MTU is 1500. That’s only true for plain Ethernet. Add any encapsulation at the ISP level and the numbers shift:
PPPoE (very common on DSL/FTTH):
PPPoE adds 8 bytes of overhead, reducing your effective WAN MTU to 1492.
WireGuard MTU = 1492 − 60 = 1432 bytes
Double-NAT or another VPN underneath:
If WireGuard is running inside another VPN tunnel (common in some corporate setups or when chaining tunnels), you’re stacking encapsulation. Each layer costs bytes.
Jumbo frames on an internal LAN:
If your hosts communicate over 9000-byte jumbo frames and WireGuard is carrying that traffic, the MTU on the WireGuard interface can be larger — up to 8940 bytes or so. Most people don’t need this.
How to Find Your Actual Optimal MTU
Don’t guess. Probe it. Use ping with the "don’t fragment" bit set and binary search for the largest packet that makes it through:
# On Linux — test from inside the WireGuard tunnel
# Start with a known-safe size and work up
ping -M do -s 1380 10.0.0.1 # DF bit set, payload = 1380 bytes
ping -M do -s 1400 10.0.0.1
ping -M do -s 1412 10.0.0.1
# If 1412 passes but 1420 doesn't, your effective tunnel payload is 1412
# Set MTU = 1412 in your WireGuard config
# On macOS: same idea, different flag
ping -D -s 1380 10.0.0.1
Add 28 bytes to your test payload (20 IP + 8 ICMP headers) to get the total packet size. If -s 1380 works, your path supports 1408-byte packets end-to-end. Set your WireGuard MTU at or below that.
Setting MTU in wg-quick
# /etc/wireguard/wg0.conf
[Interface]
PrivateKey = <private-key>
Address = 10.0.0.1/24
ListenPort = 51820
MTU = 1432 # explicit MTU — adjust based on your measurement
# Optional: if you want to set it conditionally per-link
PostUp = ip link set mtu 1432 dev %i
The MTU key in the [Interface] block is the cleanest option — wg-quick handles it directly.
Gotcha: Asymmetric MTU Problems
Your client might have the right MTU set, but the server might not. If the server’s WireGuard interface still has the default 1420 MTU but you’re sending 1432-byte packets from the client, the server-side will fragment or drop those packets depending on the inner traffic. Set the MTU explicitly on both sides.
Gotcha: Docker and Container Networking
If WireGuard runs on a host that also runs Docker, containers inherit the host’s routing table but Docker’s default bridge MTU is 1500. Traffic from containers going through WireGuard gets silently fragmented or dropped. Fix it at the Docker daemon level:
// /etc/docker/daemon.json
{
"mtu": 1432
}
Then restart Docker. Alternatively, set per-network MTU in your Compose files:
networks:
default:
driver: bridge
driver_opts:
com.docker.network.driver.mtu: "1432"
Roaming Clients: How WireGuard Actually Handles Them
This is where WireGuard genuinely shines compared to older VPN protocols, and where the design is counterintuitive enough to trip people up.
The Endpoint Update Mechanism
WireGuard authenticates peers by public key. It doesn’t care what IP address a packet comes from — if the Noise handshake succeeds and the packet is properly authenticated, WireGuard accepts it and silently updates its internal endpoint record for that peer. This is the key insight.
When your laptop leaves your home WiFi (192.168.1.x) and connects to a mobile hotspot (10.20.30.x), WireGuard doesn’t need any signaling. The first encrypted packet your laptop sends from the new IP triggers a handshake. The server sees the handshake, validates the public key, and updates the endpoint for that peer from the old IP to the new one. Done. No re-authentication, no session teardown, no reconnect logic needed.
This is fundamentally different from OpenVPN or IPSec, where a change in source IP usually requires a full session restart.
Configuring the Server for Roaming Clients
The server-side config for a peer that will roam is deliberately minimal — you don’t specify an Endpoint for that peer, because you don’t know it in advance:
# /etc/wireguard/wg0.conf — SERVER side
[Interface]
PrivateKey = <server-private-key>
Address = 10.0.0.1/24
ListenPort = 51820
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
# Roaming mobile client — no Endpoint specified here
[Peer]
PublicKey = <client-public-key>
AllowedIPs = 10.0.0.2/32 # only allow traffic from/to this VPN IP
# Peer with a known static IP (e.g., a remote server)
[Peer]
PublicKey = <server2-public-key>
Endpoint = 203.0.113.99:51820
AllowedIPs = 10.0.0.3/32
The client config is standard:
# /etc/wireguard/wg0.conf — ROAMING CLIENT (laptop, phone)
[Interface]
PrivateKey = <client-private-key>
Address = 10.0.0.2/24
DNS = 10.0.0.1
MTU = 1420
[Peer]
PublicKey = <server-public-key>
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25 # needed because the client is behind NAT
Gotcha: DNS Resolution Happens Once
If your Endpoint uses a hostname (vpn.example.com:51820), wg-quick resolves it to an IP when the interface comes up — and then never resolves it again until the interface is restarted. If your server’s IP changes (failover, dynamic DNS updates), the client will keep sending to the stale IP.
For servers with static IPs this is a non-issue. For dynamic IPs there are workarounds:
# Cron-based re-resolution workaround — runs every 5 minutes
# /etc/cron.d/wg-reresolve
*/5 * * * * root /usr/bin/wg-quick strip wg0 | wg setconf wg0 /dev/stdin 2>/dev/null
Or use wg set to update the endpoint directly when you detect an IP change:
NEW_IP=$(dig +short vpn.example.com)
sudo wg set wg0 peer <pubkey> endpoint "${NEW_IP}:51820"
This is a known limitation of wg-quick, not WireGuard itself. The kernel module happily accepts endpoint updates at any time.
Handling Multiple Roaming Clients
When you have many roaming clients (a team, all using the same server), key management becomes the operational bottleneck. You have two practical options:
Option 1: Static key pairs, one per device. Generate keys per device, add each as a [Peer] block on the server. Simple, auditable, explicit. Works well up to maybe 50–100 peers. Beyond that, config file management gets tedious.
Option 2: Use a management layer. Tools like wg-easy, headscale (a Tailscale control plane), or netbird automate key distribution, peer discovery, and roaming. If you have more than a handful of dynamic clients, you’ll want one of these eventually.
Roaming with Split Tunneling
Full tunnel (AllowedIPs = 0.0.0.0/0) sends everything through the VPN. If you want only certain subnets — say, office resources — to go through the tunnel while leaving internet traffic alone, use split tunneling:
# CLIENT — only route the corporate subnet through WireGuard
[Peer]
PublicKey = <server-public-key>
Endpoint = vpn.example.com:51820
AllowedIPs = 10.0.0.0/8, 192.168.100.0/24 # only these ranges use the tunnel
# No PersistentKeepalive needed if the client always initiates to resources
# in the allowed ranges — those will keep the NAT mapping fresh
WireGuard’s AllowedIPs doubles as both the routing filter and the packet-acceptance filter. Only IPs in that list are sent through the interface (as a source route), and only IPs in that list are accepted as valid incoming traffic from the peer. This makes split tunneling configuration trivial — no need for route manipulation scripts.
Production-Ready Reference Config
Here’s a clean, commented config that incorporates all three topics: proper keepalive placement, explicit MTU, and server-side setup for roaming clients.
# SERVER — /etc/wireguard/wg0.conf
[Interface]
PrivateKey = <server-private-key>
Address = 10.10.0.1/24
ListenPort = 51820
MTU = 1420 # matches default; adjust if server has unusual uplink
# Enable IP forwarding for routing (also set net.ipv4.ip_forward=1 in sysctl)
PostUp = iptables -A FORWARD -i %i -j ACCEPT
PostUp = iptables -A FORWARD -o %i -j ACCEPT
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT
PostDown = iptables -D FORWARD -o %i -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
# Roaming laptop — no Endpoint, server learns it dynamically
[Peer]
PublicKey = <laptop-public-key>
AllowedIPs = 10.10.0.2/32
# Roaming phone
[Peer]
PublicKey = <phone-public-key>
AllowedIPs = 10.10.0.3/32
# Fixed peer (remote server or branch office)
[Peer]
PublicKey = <branch-public-key>
Endpoint = branch.example.com:51820
AllowedIPs = 10.10.0.4/32, 172.16.5.0/24 # VPN IP + branch LAN
# CLIENT (laptop/phone) — /etc/wireguard/wg0.conf
[Interface]
PrivateKey = <client-private-key>
Address = 10.10.0.2/24
DNS = 10.10.0.1
MTU = 1420 # probe your specific path; adjust accordingly
[Peer]
PublicKey = <server-public-key>
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25 # client is behind NAT; server needs to reach us
Enable and start:
# One-time: enable IP forwarding on the server
echo "net.ipv4.ip_forward = 1" | sudo tee /etc/sysctl.d/99-wireguard.conf
sudo sysctl --system
# Enable and start the WireGuard interface
sudo systemctl enable --now wg-quick@wg0
# Verify tunnel health
sudo wg show wg0
Quick Diagnostics Cheatsheet
# Is the handshake happening?
sudo wg show wg0
# Check if large packets are dying (packet size 1400, DF bit set)
ping -M do -s 1400 <peer-ip>
# Watch live traffic through the interface
sudo tcpdump -i wg0 -n
# Check what MTU the interface actually has
ip link show wg0
# Force endpoint re-resolution (when DNS changes)
sudo wg set wg0 peer <pubkey> endpoint <new-ip>:51820
# Reload config without dropping the interface (careful — briefly interrupts traffic)
sudo wg syncconf wg0 <(sudo wg-quick strip wg0)
These three topics — keepalive, MTU, roaming — account for the vast majority of "WireGuard works for 5 minutes then breaks" reports you see in forums. The fixes are always small and surgical, but you have to know where to look. Now you do.