Skip to content

Docker Port Bind to a Tailscale IP Fails at Boot — Ordering Isn't Enough, Use ip_nonlocal_bind

Publishing a container port on a static Tailscale IP (ports: ["100.x.y.z:3100:3100"]) is a clean way to keep a service tailnet-only without firewall rules. But it plants a boot-time race: Docker restores containers as soon as docker.service starts, and if the tailnet IP isn't on tailscale0 yet, the bind fails. This bit the Loki stack on majorlab twice — once silently, once loudly — before landing on the durable fix.

Symptom (two variants of the same race)

Variant A — container up, no ports (hit 2026-07-07). The container shows Up, but:

docker inspect loki --format '{{json .NetworkSettings.Ports}}'
# {}   ← nothing bound, despite explicit port mappings in compose

Nothing listens on the published port; agents pushing to it get connection refused.

Variant B — container dead at start (hit 2026-07-09). The container is Exited (128) with the reason visible in docker inspect:

Error: failed to set up container networking: driver failed programming external
connectivity on endpoint loki (...): failed to bind host port
100.86.14.126:3100/tcp: cannot assign requested address

Crucially, restart: unless-stopped does not recover either variant. A network-setup failure at start isn't a container exit, so Docker never retries it — the service stays down until someone intervenes.

Cause

tailscaled reaching systemd's active state is not the same as the Tailscale IP existing on the interface. tailscaled flips to active within a second of starting, then assigns the address to tailscale0 asynchronously after it reaches the control plane. Any service that binds the tailnet IP in that window gets EADDRNOTAVAIL.

This is the same trap as Tailscale Boot Race Conditions (SSH) — see that article's "evolution of this fix" note, where After=tailscaled.service was tried and failed for exactly this reason (its v2).

Measured on majorlab, 2026-07-09 boot:

Time Event
09:05:12 tailscaled.service active — ordering satisfied
09:05:15 Docker binds Loki's ports → cannot assign requested address
09:05:16 docker.service fully active; Loki Exited (128)
~09:05:20+ Tailscale IP actually appears on tailscale0 — too late

So a docker.service drop-in with After=tailscaled.service / Wants=tailscaled.service orders correctly and still loses the race. It's kept on majorlab as belt-and-suspenders only.

Fix — ip_nonlocal_bind

Let the kernel accept binds to addresses that aren't (yet) assigned:

echo 'net.ipv4.ip_nonlocal_bind = 1' | sudo tee /etc/sysctl.d/99-tailscale-nonlocal-bind.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale-nonlocal-bind.conf

The bind succeeds immediately regardless of Tailscale timing; traffic simply starts flowing once the IP comes up. sysctl.d is applied before any service starts, so no ordering can defeat it. This is the standard approach for daemons binding VPN/VIP addresses at boot (it's how keepalived/HAProxy VIP setups work).

Verify without waiting for a reboot — bind an unassigned tailnet-range address:

python3 -c 'import socket; s=socket.socket(); s.bind(("100.86.14.99", 39999)); print("nonlocal bind OK")'

Trade-off: it's host-global — any process can now bind any IPv4 address it likes. On a single-admin homelab box this is a non-issue; the alternative below exists if that bothers you.

Rejected alternative: an ExecStartPre wait-loop (or a tailscale-wait-ready.service gate, as used for sshd in the SSH article) on docker.service. It works, but it holds all of Docker hostage — on majorlab that's Forgejo, Matrix, Nextcloud, and friends — any time Tailscale is slow, down, or logged out. Gating one socket on Tailscale is fine; gating the whole container runtime is not.

Recovery gotcha: recreate, don't restart

After the bind has failed, a plain docker start / docker compose up -d can leave a half-wired endpoint: State.Status=running but NetworkSettings.Ports: {} and no container IP. Port mappings are only programmed at container creation, so:

docker compose up -d --force-recreate <service>

Then confirm ports actually exist (docker port <service>) — don't trust Up in docker ps.