Security research

Your firewall is on. Docker is ignoring it.

Published container ports skip UFW entirely. How to find exposed services from outside, and the fixes that survive the next deploy.

Creative Code · October 8, 2026

During a security sweep of a fleet of self-hosted Linux servers, we found databases and caches answering on the public internet on hosts where the firewall was enabled and, on paper, denied those ports. Nobody had disabled anything. The firewall was simply never consulted.

What happens

When a container publishes a port (-p 5432:5432, or ports: in Compose), Docker writes its own iptables rules. Traffic to that port is rewritten by NAT before it reaches the chains UFW manages, then forwarded straight to the container. UFW's INPUT rules never see it.

Docker gives you one supported hook, the DOCKER-USER chain, which runs before its own forwarding rules. The trap is that by the time a packet reaches DOCKER-USER, its destination has already been rewritten to the container's internal address and port. A rule written the obvious way, --dport 5432, can fail to match when the published port and container port differ, and an allow-list written against the host's public address will not match either.

Two more details made it worse in practice:

  • IPv6 is a separate world. Depending on the Docker version and daemon settings, published ports can be reachable over v6 with no equivalent rules at all.
  • The bug recurs. We fixed the original stacks, and weeks later newly added Compose stacks on the same host shipped with 0.0.0.0 bindings again, because the fix lived in the firewall rather than in how services were declared.

How to check your own hosts

  1. List what is actually published: docker ps --format '{{.Names}} {{.Ports}}'. Anything showing 0.0.0.0: or [::]: is listening on every interface.
  2. Test from outside, not from the host: nc -vz <public-ip> <port> from a machine on another network. The host's own view of its firewall is the thing that is wrong.
  3. Repeat over IPv6 if the host has a public v6 address.

The fixes, in order of preference

  1. Do not publish what does not need to be public. Bind internal services to loopback or a private interface: 127.0.0.1:5432:5432, or put them on an internal Docker network with no published port at all. This is the only fix that survives the next person adding a stack.
  2. Filter on the original destination in DOCKER-USER. Match the pre-NAT port with conntrack: -m conntrack --ctorigdstport 5432, and allow only the sources you mean.
  3. Cover IPv6 explicitly, at the host or upstream, rather than assuming v4 rules apply.
  4. Make it a check, not a memory. We added an exposed-port test to every sweep so a new stack published on 0.0.0.0 is caught the week it lands.

How we verify findings

Every finding in our sweeps is reproduced from outside the host, then cross-checked by a second, independent model before anyone changes production. Read-only first, fix second, verify third.

Creative Code runs security sweeps, hardening and incident response as part of its engineering work. Talk to us.