Why Your Self-Hosted Nextcloud Slows to a Crawl Over WireGuard
The login page loads instantly, the file list appears, and then opening a 5 MB photo hangs at 0%. If that sounds familiar, the culprit is usually not Nextcloud, PHP or your disk. It is a packet that is slightly too big for the path, with nobody telling the sender.
Small things work because they fit. Big things fail because they don't. That asymmetry is the giveaway.
The symptom pattern that points at MTU
Before touching any config, check whether your problem has this shape:
- The web UI loads and you can log in.
- Directory listings work (small responses).
- Downloads or uploads start, then stall or crawl.
- Everything is fine on the LAN, and only breaks through the tunnel.
- SSH works, but
scpof a larger file freezes.
TLS is an accidental test here. A server certificate chain often runs past a thousand bytes, so if handshakes occasionally hang too, suspect MTU even harder.
Where the missing bytes go
WireGuard wraps every packet in UDP and adds its own header and authentication tag. That costs 60 bytes over an IPv4 outer path and 80 over IPv6. The default tunnel MTU of 1420 assumes a 1500-byte underlying link carrying IPv6.
That is fine on plain Ethernet. It is not fine if your uplink is PPPoE (1492), a mobile or satellite link, a cloud network with a smaller MTU, or another tunnel underneath. If the real path MTU is smaller than the tunnel assumes, full-size packets get fragmented or dropped.
Why it becomes a stall, not an error
Path MTU discovery is meant to rescue you. A router that can't forward a packet sends back an ICMP "fragmentation needed" message, and the sender shrinks its packets. Plenty of networks filter ICMP, so the message never arrives.
The sender keeps retransmitting the same oversized segment into a hole. Nothing errors; TCP just backs off and retries. That is called a PMTU black hole, and it looks exactly like "Nextcloud is slow".
Quick detour: why the handshake still works
Hang on, why does the start of every connection succeed? Because SYN packets carry no data and are tiny. The connection sets up happily, both sides agree on a segment size based on their local MTU, and the first full-sized data packet is the one that vanishes. The failure only appears once real payload flows.
Prove it with ping
Send pings with the Don't Fragment bit set and increase the size until they fail. For IPv4, the packet size is the payload plus 28 bytes of headers. Test through the tunnel first:
ping -M do -s 1392 10.8.0.1
ping -M do -s 1400 10.8.0.1
ping -M do -s 1300 10.8.0.1
A payload of 1392 makes a 1420-byte packet, the tunnel's default limit. If that fails but 1300 works, you are close. Bisect to find the largest size that passes; add 28 and you have the usable MTU.
Then test the outer path, using the public address of the other end, because that is what the encrypted packets actually travel over:
ping -M do -s 1472 203.0.113.10
tracepath 203.0.113.10
1472 plus 28 is 1500. If that fails and, say, 1464 passes, your path MTU is 1492. Subtract the WireGuard overhead (80 to be safe) and set the tunnel MTU to 1412 or lower. Note that tracepath reports the discovered path MTU, but it depends on those same ICMP messages, so a filtered path may show nothing useful. The ping bisection is the more reliable test.
Fix one: set the tunnel MTU
If Nextcloud runs on the WireGuard host itself, or each end only talks for itself, set the MTU on the interface and restart it:
[Interface]
Address = 10.8.0.2/24
PrivateKey = ...
MTU = 1380
Being a bit conservative costs you a percent or two of throughput. Being too optimistic costs you stalls. I would take the percent.
Fix two: clamp the MSS when you forward
The awkward case is a reverse proxy or VPS relaying traffic into the tunnel to a Nextcloud box behind it. TCP segments are negotiated end to end, and neither end knows about the tunnel. A router in the middle can rewrite the MSS option in SYN packets so both sides pick a smaller segment size.
With nftables on the forwarding box, this clamps to the MTU of whichever route the packet takes:
table inet mssfix {
chain forward {
type filter hook forward priority mangle; policy accept;
oifname "wg0" tcp flags syn tcp option maxseg size set rt mtu
iifname "wg0" tcp flags syn tcp option maxseg size set rt mtu
}
}
Both directions matter, since the SYN-ACK carries an MSS too. Check it worked with tcpdump -ni wg0 'tcp[tcpflags] & tcp-syn != 0' and look at the mss value in the options.
If MTU is not the problem
Not every slow Nextcloud over a VPN is an MTU issue. Two other things look similar:
- Latency, not loss: the desktop client syncs many small files, and each one costs several round trips. A tunnel to a far-off VPS multiplies that.
- Proxy buffering: a reverse proxy that buffers whole uploads to disk before passing them on makes large uploads feel frozen.
The ping test separates these quickly. If full-size DF pings pass cleanly and throughput is still poor, look at latency and the proxy instead.
One last quirk: after changing the MTU, existing TCP connections keep their old segment size. Restart the client or wait for new connections before you decide whether the fix worked.