Docker Compose Egress and the Stale Legacy iptables Ruleset
Your Compose stack is healthy, your host reaches the internet, and your containers have none. A second firewall ruleset was dropping everything before NAT — here is how to find it.
Tested against Docker 29.7.2 · Debian trixie · iptables-legacy vs nftables · August 2026
The symptom: a health check that hangs forever
Your Docker Compose stack is up. Every container reports healthy. And your agent cannot reach the internet at all. The model-reachability probe — a one-line claude -p call from inside the container — never returns. No error, no timeout message, no stack trace. Just silence, forever, retried.
We hit this while wiring up a Telegram demo for an agent stack running in Compose. The container could reach its siblings on the compose network and the host could reach the internet, but the container itself had zero outbound connectivity. Here is the trail, including the part that made it take far too long: everything you normally check looked correct.
| Check | What it showed |
|---|---|
docker compose ps | every container Up, api and restate healthy |
curl from the host to the internet | fast, normal egress |
curl from inside the container to the internet | hangs — no error, no timeout, nothing |
claude -p inside the container (the PONG check) | hangs identically |
The red herrings
The default Docker bridge (docker0) had working egress. A throwaway container on that bridge reached 1.1.1.1 in milliseconds. Only traffic from the compose bridge was being dropped — which is exactly the kind of difference that sends you looking in the wrong place.
| Layer | Verdict | Detail |
|---|---|---|
| Docker's own nftables rules | looked correct | FORWARD policy accept, the compose bridge accepted, a MASQUERADE rule present for the subnet. |
| Host routing | looked correct | ip_forward = 1, default route present, the compose bridge UP with its veths. |
| The actual drop | invisible to nft | A second, stale iptables-legacy ruleset ran FORWARD with policy DROP and only knew docker0. |
The tell was a counter. Docker's nftables NAT rules showed a MASQUERADE rule for the compose subnet — but its packet counter sat at zero while the FORWARD counters were ticking. Traffic was arriving at the host and being forwarded… and then never reaching NAT. Something was dropping it in between.[1]
Two firewall rulesets, one of them stale
The machine ran both iptables backends at once: Docker's modern nftables rules, and a leftover legacy xtables ruleset from an older install that had never been cleaned up. The kernel runs both hooks, in sequence.
The legacy ruleset's FORWARD chain had policy DROP and only knew about the default docker0 bridge:
$ sudo iptables-legacy -L FORWARD -n -v
Chain FORWARD (policy DROP ...)
DOCKER-USER all -- * * ...
DOCKER-FORWARD all -- * * ...
# no rule matching br-<compose-bridge> — everything from it falls to policy DROPThe compose bridge's traffic was accepted by Docker's nftables FORWARD chain, then dropped a millisecond later by the stale legacy chain's policy. From the outside, the stack looked flawless. Container-to-container traffic on the same bridge worked because it never leaves the bridge; only traffic that had to be forwarded out — which is the traffic that matters — died at the legacy policy.
The fix: accept the compose bridge in the legacy chains
Three rules in the legacy ruleset mirror what Docker's nftables rules already do for docker0: accept outgoing traffic from the compose bridge, accept return traffic into it, and masquerade its subnet out.[2] The bridge name and subnet are derived from the running stack so the same commands work on any machine:
# Derive the compose network's bridge and subnet from the running stack,
# so the same commands work on any machine.
CONTAINER=$(docker compose ps -q api)
NETWORK=$(docker inspect "$CONTAINER" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}')
BRIDGE=br-$(docker network inspect "$NETWORK" --format '{{.Id}}' | cut -c1-12)
SUBNET=$(docker network inspect "$NETWORK" --format '{{(index .IPAM.Config 0).Subnet}}')
sudo iptables-legacy -I DOCKER-FORWARD -i "$BRIDGE" -j ACCEPT
sudo iptables-legacy -I DOCKER-CT -o "$BRIDGE" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
sudo iptables-legacy -t nat -A POSTROUTING -s "$SUBNET" ! -o "$BRIDGE" -j MASQUERADEThe DOCKER-CT rule is the one that is easy to miss — allowing only the outbound direction fixes nothing, because the replies coming back into the bridge hit the same stale policy DROP. You need both directions, exactly as Docker's own rules provide for docker0.
The lesson: “the rules look right” can mean you looked at the wrong ruleset
The expensive part of this debugging was not the fix — it was that every normal diagnostic pointed at a healthy system. Docker's rules were correct. The host's routing was correct. The only signal that something was wrong was a NAT counter that refused to move, and the only way to see the true culprit was to check the other ruleset explicitly.
- If a container's egress is dead but
docker0works, suspect a second ruleset — checkiptables-legacy -L FORWARD -n -veven when nft looks fine. - Counters are evidence: a MASQUERADE rule that has never matched means traffic is dying before NAT, not after.
- Ask the remote end. A
curlfrom inside the container is worth a hundred reads of the firewall config, because it tests the whole path.
Sources
Frequently Asked Questions
Why would Docker Compose containers lose internet when the host still has it?
A second, stale iptables-legacy ruleset can coexist with Docker's nftables rules. If its FORWARD chain has policy DROP and only accepts the default docker0 bridge, traffic from the compose bridge is accepted by one ruleset and dropped by the other before it ever reaches NAT.
How do I tell if my container egress is being dropped before NAT?
Check the NAT counters. If the MASQUERADE rule for your compose subnet counts zero packets while the FORWARD counters are ticking, traffic is being dropped between FORWARD and POSTROUTING — look for a second ruleset with iptables-legacy -L FORWARD -n -v.
Is the fix permanent?
No. iptables-legacy rules are runtime-only and disappear on reboot. Persist them in a script or systemd unit, or the hang will return on a fresh boot.