Skip to main content
RunBook Academy

Docker & ContainersXXVII · FirewallsThe bypass

Published ports bypass the host firewall

Advanced⏱ ~24 min

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

Not yet marked complete on this device.

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 docker whose target is ACCEPT, and
  • a forwarding policy named docker-forwarding that allows forwarding from any zone into the docker zone.

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.

Read-only / Safefirewalld reality check
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-all

The wrong fixes

These come up in every incident review. None of them close the hole.

Proposed fixWhat it actually does
icc=false in daemon.jsonBlocks container-to-container traffic on the default bridge. No effect on published ports.
Removing EXPOSE from the DockerfileEXPOSE is metadata. It never opened anything.
ufw deny 5432Adds a rule to ufw-user-input, a chain the packet does not traverse.
ufw reloadReloads ufw’s own rules. Docker’s chains are untouched.
ufw disable && ufw enableSame, 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:

  1. 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 in docker-limiting-exposure-by-design.
  2. Filter in DOCKER-USER. This is the chain Docker provides for exactly this purpose and never overwrites. Covered in docker-user-chain-and-rule-persistence.
  3. Set "iptables": false and 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.

Read-only / Safeintent
$ # 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
Read-only / Safereality
$ # 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 seconds

Illustrative 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

  1. Q1. A packet arrives for a published container port. Which chain does it traverse after the routing decision?

  2. Q2. ufw does install rules under FORWARD and sets DEFAULT_FORWARD_POLICY to DROP. Why does that still not block a published port?

  3. Q3. Which of these change whether a published port is reachable from another host? Select all that apply.

  4. 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.