A WireGuard MTU Black Hole Stalled My Remote AdGuard Pi for 90 Seconds
A path abroad silently dropped WireGuard packets above 1392 bytes. MTU 1360 took my remote AdGuard Pi from a 90 s stall to 7 s and loss from 10% to 0%. Then the hardening.
HomeLab, Network and DNS. Updated . 5 min read.
Ping to the Pi worked. SSH worked. DNS answered. But the AdGuard Home login page on the Pi at my family's place abroad sat blank for 90 seconds before it drew anything, and the camera stream behind it never started. Everything small got through. Everything big died, and no ICMP error ever came back.
TL;DR: something on the path dropped tunnel packets above 1392 bytes and sent no ICMP message back. WireGuard's default MTU of 1420 makes every full-size TCP segment bigger than that, so every bulk transfer stalled. MTU = 1360 in wg0.conf took the login page from a 90 s stall to 7 s and packet loss from 10% to 0%. While I had the box open, I set it up to look after itself: a 14 s hardware watchdog, services that wait for the tunnel, DNS timeouts for a lossy line, and an SD card that went from 39% to 11% used.
The box
It's a Raspberry Pi 3B with 1 GB of RAM, and it's the DNS server for the house, running AdGuard Home. The line is slow and capped, about 8 Mbit/s, and the Pi's upload is about 0.75 Mbit/s. I reach it over WireGuard: the Pi connects to a small VPS that acts as the hub, and my home network reaches the same hub from the router.
Two facts shape every decision on this box. The tunnel is the only way in, and nobody on site can power cycle it. So a cron job restarts wg-quick@wg0 every hour. It looks crude, but it's the only way back if the tunnel wedges, and I leave it alone. I also never restart networking or wg0 by hand. Over this tunnel, that's how you lock yourself out.
Small packets pass, big ones vanish
The pattern is the tell. Ping, DNS, SSH keystrokes: all small packets. The AdGuard UI's JavaScript is 660 KB gzipped, which is a long run of full-size TCP segments. Those never arrived.
So I pinged the Pi's tunnel address from my side of the tunnel, with the don't-fragment bit set, a few bytes at a time:
ping -M do -s 1364 <pi-tunnel-address> # passes
ping -M do -s 1368 <pi-tunnel-address> # nothing comes back-s is the ICMP payload. Add 8 bytes of ICMP header and 20 of IP header, and the first ping is a 1392-byte packet inside the tunnel. The next step, 1396 bytes, just disappears. No "fragmentation needed" error, no reply, nothing.
Why the default MTU fails here
WireGuard's default interface MTU is 1420. TCP sizes its segments from the MTU, so a full segment on wg0 is a 1420-byte packet, 28 bytes over what this path would carry.
Normally path MTU discovery covers this. A router that can't forward a big packet with DF set sends back an ICMP "fragmentation needed", and the sender shrinks its packets. When that ICMP never comes back, you get a black hole. The sender retransmits the same full-size segment, it dies again, and the connection sits there. Meanwhile every quick health check uses small packets, so the link looks fine.
The fix is one line
In /etc/wireguard/wg0.conf on the Pi, after a backup of the old file:
[Interface]
MTU = 13601360 leaves some room under the 1392 limit. The hourly restart applies it, and I checked that it's still 1360 after that restart runs. With a smaller MTU on its tunnel interface, the Pi also advertises a smaller TCP MSS, so the other end sends segments that fit too.
| Before | After | |
|---|---|---|
| AdGuard login JS | 90 s stall | 7 s |
| Packet loss, home to Pi | 10% | 0% |
The 7 s is the line now, not the tunnel. 660 KB at 0.75 Mbit/s of upload is about 7 seconds. The page loads as fast as the Pi's upload lets it.
Services that started before the tunnel
The camera forwarders on the Pi bind to its tunnel address. On boot they started before wg0 had that address, failed, and hit systemd's start limit. After that they stayed down. Power cuts there are frequent, four in the week before I looked, so this happened a lot.
Each unit got a drop-in: ordered after wg-quick@wg0, with Restart=always, RestartSec=5, and StartLimitIntervalSec=0. Now a failed start retries every 5 seconds with no limit, instead of giving up after a few tries.
A DNS server on a lossy line
AdGuard's defaults assume a decent connection. These are the settings I changed in AdGuardHome.yaml:
upstream_timeout: 3s, down from 10s. On this line a lost UDP query is normal, and each one cost 10 to 20 seconds before AdGuard asked the next upstream.cache_optimistic: truewithcache_optimistic_max_age: 72h, up from 12h. After a quiet night, morning lookups still hit the cache instead of waiting on the line.cache_ttl_min: 600.- Upstreams are Cloudflare and Google only, load balanced. The others I had gave different block answers.
The cache is RAM only, so a power cut empties it anyway. That's the trade-off I accept: after each cut, the first lookups go to the upstreams.
Nobody can reboot it
If this Pi hangs, it stays hung until the next power cut. So it now has a hardware watchdog:
# /etc/systemd/system.conf.d/watchdog.conf
RuntimeWatchdogSec=14systemd keeps the Pi's BCM2835 watchdog fed. If the kernel or systemd hangs and stops feeding it, the board resets itself after 14 seconds. It costs one config line and nothing at runtime.
An SD card full of logs
The card was at 39%, and most of that was logs and leftovers:
- The AdGuard query log kept 90 days, 1.5 GB. Now it keeps 7 days. Stats still keep 90.
- AdGuard's stdout and stderr went to
/var/log/AdGuardHome.err, which had grown to 646 MB. A drop-in sends them to the journal now, so the file is gone and can't come back.journalctl -u AdGuardHomereads it. - rsyslog was writing the same logs the journal already holds. I turned it off and deleted the syslog files.
- The journal is capped at 100 MB, which freed 2.7 GB.
- Docker was installed with no containers, still holding an old 1.7 GB Home Assistant image. Removing its data freed 2 GB, and docker and containerd are off.
I also set vm.swappiness=10 and turned off services the box doesn't need: ModemManager, Bluetooth, Avahi, and wpa_supplicant, since the Pi is on Ethernet. The card ended at 11% used, 3.1 GB.
What's still open
The power cuts are the real problem. Each one is an unclean shutdown and an ext4 journal replay. vcgencmd get_throttled says 0x0, so it isn't undervoltage. A small DC UPS for the Pi and the router is the fix, and that needs someone to plug it in.
The OS is due for a reinstall, but not on a box I can only reach through a tunnel it hosts. That waits for someone on site.
And no amount of tuning adds bandwidth. The line cap is the limit. The one real upgrade left is SQM with CAKE on the router, so one download stops freezing calls, if the router supports it.
Questions
- Why does a web page stall over WireGuard while ping and SSH work?
- Ping and SSH keystrokes are small packets. A page load needs full-size TCP segments, which with WireGuard's default MTU of 1420 are 1420-byte packets inside the tunnel. If something on the path drops packets above a lower size and sends no ICMP fragmentation-needed message back, path MTU discovery never kicks in and those segments are lost on every retry. That is a path MTU black hole.
- How do I find the right WireGuard MTU for a bad path?
- Ping the peer's tunnel address with the don't-fragment bit set and step the payload size: ping -M do -s 1364 passed and -s 1368 failed on my path. Add 28 bytes (8 ICMP, 20 IP) to the largest passing payload to get the largest packet that gets through, here 1392 bytes. Set MTU in the [Interface] section of wg0.conf a little below that. I used 1360.
- How do I make a remote Raspberry Pi reboot itself if it hangs?
- Turn on the systemd hardware watchdog: RuntimeWatchdogSec=14 in a drop-in under /etc/systemd/system.conf.d/. systemd then keeps the Pi's BCM2835 watchdog fed, and if the kernel or systemd hangs, the board resets after 14 seconds.
- How do I stop AdGuard Home from filling a Raspberry Pi SD card?
- Cut the query log retention (mine was 90 days and 1.5 GB, now 7 days), send AdGuard's stdout and stderr to the journal instead of a file under /var/log, cap the journal size, and turn off rsyslog if the journal already holds the logs. My card went from 39% to 11% used.