DockerSelf-hosting

Docker bypasses ufw: why published ports stay open, and how to close them

Illustration: a cargo ship labelled Port 3000 sits at the dock with its doors wide open, while one character studies a map and another cranks a striped barrier shut.

Yes, Docker bypasses ufw. When you publish a container port, Docker rewrites the traffic in the NAT table before it reaches the chains where ufw keeps its rules, so a ufw deny 8080 does nothing for a container published on 8080. The fix is to publish fewer ports, bind the ones you need to localhost, or filter them in the DOCKER-USER chain.

This catches many people running a VPS with a reverse proxy and a handful of containers. The firewall looks right, ufw status looks right, and the database admin panel is still reachable from anywhere.

Why ufw doesn't see container traffic

ufw is a front end for iptables. The rules you add with ufw allow and ufw deny live in chains attached to INPUT, which handles packets addressed to the host itself.

A published container port works differently. Docker adds a destination NAT rule that rewrites a packet arriving on host port 8080 to the container's internal address. After that rewrite, the packet is no longer for the host: the kernel forwards it to the container through the FORWARD chain, and INPUT never sees it. Docker's documentation on packet filtering describes the result in plain terms: the container's traffic is diverted before ufw's settings apply, effectively ignoring your firewall configuration.

firewalld has a version of the same issue. When Docker manages its rules alongside firewalld, it creates a zone called docker with target ACCEPT, plus a forwarding policy named docker-forwarding that allows forwarding from any zone into that zone.

Docker's own page on port publishing puts it bluntly: publishing container ports is insecure by default. A published port becomes available to the outside world, not just the Docker host.

What "ports: 8080:80" really means

In a Compose file, this line:

ports:
  - "8080:80"

publishes container port 80 on host port 8080 on every host address, which Docker writes as 0.0.0.0 for IPv4 and [::] for IPv6. If your server has a public IPv6 address, the container is reachable over IPv6 as well.

To list what each running container publishes:

docker ps --format '{{.Names}}\t{{.Ports}}'

Any entry that starts with 0.0.0.0: or [::]: is listening on all interfaces. An entry starting with 127.0.0.1: is bound to localhost only. Containers with nothing in the second column publish no ports.

Check what is reachable from outside

ufw status tells you the policy you wrote, not the ports the internet can reach. The honest test is a scan from another network, aimed at your own server and nothing else. A phone hotspot or a small VPS somewhere else works:

nmap -Pn -p- YOUR_SERVER_IPV4
nmap -6 -Pn -p- YOUR_SERVER_IPV6

-p- scans all 65,535 TCP ports and -Pn skips the ping check, which firewalls often drop. Compare the open ports with the list you expect. Usually that is 22 for SSH plus 80 and 443 for the proxy. Anything else is a container worth a closer look: a database, an admin dashboard, a metrics endpoint, a development server someone forgot.

Only scan addresses you own or have written permission to test. Scanning other people's infrastructure can break their terms of service and, depending on your country, the law.

Fix 1: put the proxy on a Docker network and publish nothing else

If your reverse proxy (Caddy, Traefik, nginx) also runs in Docker, the application containers don't need published ports at all. Containers on the same user-defined network reach each other by service name:

services:
  app:
    image: myapp
    networks: [web]
  db:
    image: postgres:17
    networks: [web]
  caddy:
    image: caddy:2
    ports:
      - "80:80"
      - "443:443"
    networks: [web]
networks:
  web:

Only the proxy publishes ports. In the proxy config you point to app:3000 (or the port your app listens on inside its container). The database is reachable from the app, and from nothing outside the network.

This is the cleanest setup because nothing depends on firewall rules being right.

Fix 2: bind published ports to localhost

If the proxy runs directly on the host instead of in a container, bind the published port to the loopback address:

ports:
  - "127.0.0.1:8080:80"

The equivalent for docker run is -p 127.0.0.1:8080:80, and -p '[::1]:8080:80' for IPv6. The host's proxy can reach the container, and the outside world can't.

One warning from Docker's docs: in releases older than 28.0.0, hosts on the same layer-2 segment, for example machines on the same switch, could still reach ports published to localhost. Run docker version on older servers and upgrade if needed.

You can also change the default so that a port without an explicit address lands on localhost. In /etc/docker/daemon.json, the ip key sets the address for the default bridge network, and default-network-opts covers user-defined networks, which is what Compose creates for each project:

{
  "ip": "127.0.0.1",
  "default-network-opts": {
    "bridge": {
      "com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
    }
  }
}

Restart the Docker daemon afterwards and recreate the containers so the new binding applies.

Fix 3: filter in the DOCKER-USER chain

Some ports need to stay published but only for certain sources, such as an internal API that a partner calls from a fixed range. Docker leaves an empty chain called DOCKER-USER for your own rules and processes it before its own forwarding rules.

The example from Docker's docs drops traffic to containers unless it comes from one subnet (replace ext_if with your external interface name, such as eth0):

iptables -I DOCKER-USER -i ext_if ! -s 192.0.2.0/24 -j DROP
iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPT

-I inserts at the top of the chain, so the second command ends up first. Replies to connections that were already accepted pass before the DROP rule is evaluated.

There is a subtlety: by the time packets reach DOCKER-USER, the destination address and port have already been rewritten. To match on the port clients originally connected to, use the conntrack match:

iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 80 -j ACCEPT

Docker doesn't save these rules for you. Persist them the way you persist other iptables rules on your distribution, and re-run the outside scan after a reboot to confirm they survived.

FAQ

Does ufw block Docker containers?

Not for published ports. Docker routes that traffic through NAT and the FORWARD chain, so the usual ufw deny rules on INPUT never apply to it. Containers without published ports aren't exposed on the host's public address.

Is it safe to set iptables to false in Docker?

Docker's documentation says the option is not appropriate for most users and is likely to break container networking. Without replacement rules, containers on bridge networks can't reach the internet, and their ports are still accessible to hosts on the local network.

Does binding to 127.0.0.1 protect a Docker port?

On Docker 28.0.0 and later, a port published to 127.0.0.1 is reachable only from the host itself. On older releases, machines on the same local network segment could still reach it, so check your version.

Sources