Docker + Tailscale Network Fix
Note: these scripts were written by an LLM (Claude Code), diagnosing and fixing an issue live on one specific machine (Fedora + NetworkManager + Tailscale + Docker). They worked there, but review them yourself before running as root on another machine — network/firewall behavior varies across distros, Tailscale versions, and configurations.
Fixes Docker containers (e.g. ddev) losing internet access when Tailscale is running with an exit node ("route all traffic") enabled.
The problem
With an exit node and "route all traffic" enabled, Tailscale installs a catch-all ip rule that captures every packet
not explicitly exempted and routes it out via the tailscale0 interface. Tailscale exempts its own daemon traffic from
that capture with a firewall mark, but has no way to exempt traffic that only passes through the host (forwarded)
rather than originating from it.
Docker containers' outbound traffic is exactly that: forwarded, not locally-originated. It gets no exemption, so it's
swept into Tailscale's catch-all route, sent out via tailscale0, and dropped by Tailscale's own anti-spoofing firewall
rule (ts-forward) because it isn't a recognized Tailscale-tunneled flow.
Symptom: every DNS lookup / outbound HTTP request from inside a container hangs until timeout whenever Tailscale is
connected — WP core update checks, plugin license pings, composer/npm installs, anything. The host's own
applications aren't affected, since their traffic is locally-originated and gets Tailscale's exemption mark.
The fix
A higher-priority ip rule that routes Docker's private bridge subnet (default: 172.16.0.0/12, covering Docker's
default address pool) through the normal routing table instead of Tailscale's catch-all. This doesn't weaken the VPN for
anything else on the host — it only affects packets forwarded from Docker's own bridge networks.
The rule is kept in place by a systemd timer that re-checks/re-adds it every 15s (and 5s after boot), rather than hooked
to a network event: tailscale0 is a tun device created directly by tailscaled, not through a NetworkManager
connection profile, so NetworkManager never fires dispatcher/udev events we could reliably hook into for it (confirmed
by testing — don't waste time re-trying that route).
Install
sudo ./install.sh
Optional overrides if Docker's default address pool doesn't apply on your machine (e.g. you have 15+ concurrent Docker networks and Docker has fallen back to a different private range):
sudo ./install.sh --subnet 172.16.0.0/12 --priority 5200 --table main
Verify
ip rule list | grep 172.16.0.0/12
ddev exec curl -sS -o /dev/null -w '%{time_total}\n' https://api.wordpress.org
Uninstall
sudo ./uninstall.sh
Caveats
- Only covers Docker networks within
172.16.0.0/12. If you have more than ~15 concurrent Docker networks on one machine, Docker falls back to192.168.0.0/20blocks and then10.0.0.0/8— those aren't covered by default. Don't just widen the subnet to192.168.0.0/16or10.0.0.0/8to compensate: those ranges are also real LAN/VPN subnet-route space, and blanket-exempting them could route traffic around the tunnel that's actually supposed to go through it. If you hit this, either raise--subnetto the specific overflow range Docker actually assigned, or extend the script to enumerate livedocker network inspectsubnets instead of using a fixed range. - Assumes
tailscaled's own ip rules stay in the 5210+ priority range observed on the machine this was built on; if a future Tailscale version changes that, priority 5200 should still safely sit above it as long as it's below whatever Tailscale uses for its catch-all rule.