Docker & ContainersXXVII · FirewallsThe bypass
Published ports bypass the host firewall
What you'll learn
- Trace a published-port packet through netfilter and show where the host firewall is skipped
- Explain why ufw status and firewall-cmd report a closed host that is not closed
- Reject the common wrong fixes (icc=false, EXPOSE, ufw reload)
- Prove the actual exposure from off the host
Prerequisites
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-11
A host runs ufw default deny incoming. ufw status lists one rule:
22/tcp ALLOW Anywhere. Somebody starts a database:
docker run -d --name db -p 5432:5432 -e POSTGRES_PASSWORD=REPLACE_ME postgres:16
Who can reach PostgreSQL on that host?
Anyone who can route a packet to it. The firewall is on, the firewall
is denying, and the port is open to the world. ufw status will never
mention it.
This is not a Docker bug and it is not a ufw bug. It is what happens when two things write netfilter rules and only one of them reports on what it wrote.
Where the packet actually goes
A packet arriving on the external interface for TCP 5432 takes this path:
flowchart TB
NIC[Packet arrives on eth0<br/>dst 203.0.113.10:5432]
PRE["nat PREROUTING<br/>-j DOCKER"]
DNAT["DNAT to 172.17.0.2:5432"]
ROUTE{Routing decision<br/>after DNAT}
FWD["filter FORWARD"]
DU["DOCKER-USER"]
DF["DOCKER-FORWARD"]
DOK["DOCKER — ACCEPT"]
INPUT["filter INPUT<br/>(ufw-user-input lives here)"]
NIC --> PRE --> DNAT --> ROUTE
ROUTE -->|destination is now<br/>the container, not the host| FWD
ROUTE -.->|never taken| INPUT
FWD --> DU --> DF --> DOK
Two things happen before any host firewall rule is consulted.
The destination is rewritten first. Docker’s DNAT rule lives in
the nat table’s PREROUTING chain, which runs before the routing
decision. By the time the kernel decides where the packet is going,
the destination is 172.17.0.2:5432, not the host.
The packet is then forwarded, not delivered locally. A packet
destined for an address the host does not own goes to FORWARD, not
to INPUT. Every rule ufw allow and ufw deny creates lands in
ufw-user-input, which hangs off INPUT. That chain is not on this
packet’s path.
The upstream documentation states it plainly: traffic to and from a
container with published ports “gets diverted before it goes through
the ufw firewall settings”, because Docker “routes container traffic
in the nat table”.
The same hole with firewalld
firewalld hosts are not better off; they are differently worded. When firewalld is running and Docker’s iptables integration is enabled, Docker creates:
- a firewalld zone named
dockerwhose target isACCEPT, and - a forwarding policy named
docker-forwardingthat allows forwarding from any zone into thedockerzone.
So firewall-cmd --list-all on the public zone shows a tight
ruleset, and it is accurate about the public zone. The container
traffic is simply not governed by it.
sudo firewall-cmd --get-active-zones
sudo firewall-cmd --zone=public --list-all
sudo firewall-cmd --zone=docker --list-all
sudo firewall-cmd --get-policies
sudo firewall-cmd --policy=docker-forwarding --list-allThe wrong fixes
These come up in every incident review. None of them close the hole.
| Proposed fix | What it actually does |
|---|---|
icc=false in daemon.json | Blocks container-to-container traffic on the default bridge. No effect on published ports. |
Removing EXPOSE from the Dockerfile | EXPOSE is metadata. It never opened anything. |
ufw deny 5432 | Adds a rule to ufw-user-input, a chain the packet does not traverse. |
ufw reload | Reloads ufw’s own rules. Docker’s chains are untouched. |
ufw disable && ufw enable | Same, plus a window with no host firewall at all. |
What actually works
Three options, in rough order of how often they are the right answer:
- Do not publish the port to the world. Bind the publish to the
loopback and put a reverse proxy in front:
-p 127.0.0.1:5432:5432. Covered indocker-limiting-exposure-by-design. - Filter in
DOCKER-USER. This is the chain Docker provides for exactly this purpose and never overwrites. Covered indocker-user-chain-and-rule-persistence. - Set
"iptables": falseand manage forwarding and NAT yourself. Honest, total, and rarely what you want — upstream warns it “is likely to break container networking”.
Proving it, not assuming it
Every ufw and firewalld subcommand reads its own configuration. None of them can see Docker’s chains. The only check that reflects what the kernel does is a scan from a different machine, run after the containers are up.
$ # On the Docker host: what did we intend to publish?
docker ps --format 'table {{.Names}} {{.Ports}}'NAMES PORTS
db 0.0.0.0:5432->5432/tcp, [::]:5432->5432/tcp
web 127.0.0.1:8080->80/tcp$ # From a DIFFERENT host, after the containers are running
TARGET=203.0.113.10
nmap -Pn -p 22,80,443,5432,8080 "$TARGET"Starting Nmap 7.94 ( https://nmap.org )
Nmap scan report for 203.0.113.10
Host is up (0.0012s latency).
PORT STATE SERVICE
22/tcp open ssh
80/tcp closed http
443/tcp closed https
5432/tcp open postgresql
8080/tcp closed http-alt
Nmap done: 1 IP address (1 host up) scanned in 0.31 secondsIllustrative output
The 0.0.0.0: prefix in docker ps is the tell. Read the ports
column as an exposure report, not as a label.
Sanity check
Knowledge check · 4 questions
Q1. A packet arrives for a published container port. Which chain does it traverse after the routing decision?
Q2. ufw does install rules under FORWARD and sets DEFAULT_FORWARD_POLICY to DROP. Why does that still not block a published port?
Q3. Which of these change whether a published port is reachable from another host? Select all that apply.
Q4. On a firewalld host, a tight ruleset in the public zone constrains traffic to published container ports.
Passing score: 75%. Answers are checked in this browser.