Why ufw Does Not Block Your Docker Ports

20 September 2026

ufw status on one of our servers listed three open ports: SSH, HTTP and HTTPS. A fourth port was answering requests from the public internet at the same moment.

ufw was not broken, not misconfigured, and not bypassed by a bug. It was answering a different question than the one we were asking it, and its answer was correct.

Where the packets actually go

When you publish a container port — -p 8080:8080, or a ports: entry in Compose — Docker writes a DNAT rule into netfilter's nat table, in the PREROUTING chain:

-A DOCKER ! -i br-xxxx -p tcp --dport 8080 -j DNAT --to-destination 172.18.0.3:8080

PREROUTING runs before the kernel decides whether a packet is for this machine. By the time that decision happens, the destination has already been rewritten to the container's address. The packet is no longer addressed to the host, so it is not delivered locally — it is forwarded, and it traverses the FORWARD chain.

ufw writes its rules into INPUT.

That is the whole mechanism. A published container port never passes through the chain ufw is filtering, so no ufw rule can affect it, and ufw status has nothing to report about it. The two systems are looking at different traffic.

What that cost us

A management interface that we believed was firewalled was reachable from the public internet. We found it by probing from a machine outside our own network, which is the only method that would have found it, and closed it the same hour.

Every dashboard was green throughout, because nothing was down. That is the uncomfortable part: this class of problem produces no alert, no error and no symptom. It produces a port that answers.

Two ways to fix it

Bind to loopback. -p 127.0.0.1:8080:8080 instead of -p 8080:8080. Docker still publishes, but only where a remote client cannot reach. If a reverse proxy on the same host is the only thing that should talk to the container, this is the correct fix and it needs no firewall rule at all.

Or filter the chain the traffic actually uses. Docker provides a DOCKER-USER chain that is consulted early in FORWARD, specifically so operators have somewhere to put rules that Docker will not overwrite:

iptables -I DOCKER-USER 1 -i <public-if> -p tcp --dport 8080 -j DROP

We used the second as an immediate stopgap because it needs no container restart, then replaced it with the first and removed the stopgap. Leaving both in place would have meant two controls where one is documented and the other is a surprise for whoever reads the compose file next.

Then it bit us from the opposite direction

Hours later we moved a database onto the host — a normal service, listening on a normal socket, not in a container. Another machine could not reach it and we lost time before we understood why.

A host service is addressed to the host. It is delivered locally. It traverses INPUT, where ufw's default-deny applies — and it was denied, correctly.

Every other service those two machines exchanged traffic on had been Docker-published, and therefore silently exempt from the firewall for its whole life. The first host-level service we added was the first one ufw had ever actually filtered. We had been reasoning about a firewall that had never once been in the path.

The rule we took away

ufw status is a description of the rules in INPUT. It is not a description of what your machine is reachable on.

The only instrument that answers "what is reachable" is a probe from outside the network, from a host that does not share your LAN, your tunnels or your routing. We keep one for exactly this. It is the cheapest security tooling we own and it has found things nothing on the machine itself could see.

Related