All posts
Reliability Engineering

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.

CheckWhat it showed
docker compose psevery container Up, api and restate healthy
curl from the host to the internetfast, normal egress
curl from inside the container to the internethangs — 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.

LayerVerdictDetail
Docker's own nftables ruleslooked correctFORWARD policy accept, the compose bridge accepted, a MASQUERADE rule present for the subnet.
Host routinglooked correctip_forward = 1, default route present, the compose bridge UP with its veths.
The actual dropinvisible to nftA 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 DROP

The 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 MASQUERADE

The 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 docker0 works, suspect a second ruleset — check iptables-legacy -L FORWARD -n -v even 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 curl from inside the container is worth a hundred reads of the firewall config, because it tests the whole path.

Sources

  1. Docker and iptables — Docker documentation
  2. iptables-legacy(8) — Debian man page

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.