Tailscale's exit-node "route all traffic" mode captures Docker's forwarded container traffic into its tunnel and drops it as an unrecognized flow, causing every outbound request from inside a container to hang until timeout. This installs a systemd timer that keeps an ip rule in place routing Docker's bridge subnet around Tailscale's catch-all route instead. Co-Authored-By: Claude Code <noreply@anthropic.com>
3.7 KiB
ddev-tailscale-docker-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.