Add docker-tailscale-route: fix Docker losing internet under Tailscale exit-node

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>
This commit is contained in:
2026-07-30 12:34:35 +04:00
commit 3886d956cc
6 changed files with 283 additions and 0 deletions

90
README.md Normal file
View File

@@ -0,0 +1,90 @@
# 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
to `192.168.0.0/20` blocks and then `10.0.0.0/8` — those aren't
covered by default. Don't just widen the subnet to `192.168.0.0/16`
or `10.0.0.0/8` to 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 `--subnet` to the specific overflow range
Docker actually assigned, or extend the script to enumerate live
`docker network inspect` subnets 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.