Why UFW doesn't block Docker ports
I denied a port in UFW, but the container behind it was still reachable from the internet. It took me a while to stop blaming UFW and start reading iptables.
A problem
I was hardening a Linux server. The plan was simple: turn on UFW, allow SSH, HTTP and HTTPS, deny everything else.
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw allow 80,443/tcp
sudo ufw enable
ufw status looked exactly how I wanted. Then, just to be sure, I scanned the server from my laptop:
nmap -p 1-10000 my-server
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
443/tcp open https
5432/tcp open postgresql ← ???
8080/tcp open http-proxy ← ???
PostgreSQL and an internal admin app, open to the whole internet. Both were Docker containers, started with -p 5432:5432 and -p 8080:8080. And UFW, which was supposed to deny everything else, was apparently fine with that.
My first thought was that UFW was broken. It wasn’t. It was doing exactly what it was told. It just never got to see those packets.
What UFW actually is
UFW isn’t a firewall on its own. It’s a friendly front end for iptables (or nftables on newer systems). When you run ufw allow 80, it writes iptables rules into its own chains, which hang off the kernel’s INPUT chain.
That word, INPUT, turned out to be the whole story.
Linux has a few places where it filters packets, and which one a packet goes through depends on where it’s going:
| chain | packets that… |
|---|---|
INPUT |
are addressed to this machine |
FORWARD |
are passing through this machine to somewhere else |
OUTPUT |
are created by this machine |
UFW’s rules live in INPUT. So UFW only filters packets that are going to the host itself.
What Docker does when you publish a port
When I ran docker run -p 8080:80 app, Docker did something I had never looked at. It added its own iptables rules:
sudo iptables -t nat -L DOCKER -n
Chain DOCKER (2 references)
target prot source destination
DNAT tcp 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80
That’s a DNAT rule (destination NAT). Any packet arriving for port 8080 gets its destination rewritten to the container’s private IP, 172.17.0.2:80. And it happens in the nat table’s PREROUTING chain, which runs before the kernel decides where the packet is going.
Following one packet
Once I put the two halves together, it made sense:
packet from the internet → my-server:8080
│
├─ nat / PREROUTING
│ Docker's DNAT: destination is now 172.17.0.2:80
│
├─ routing decision
│ "172.17.0.2 isn't me, it's behind the docker0 bridge" → FORWARD
│
├─ filter / FORWARD
│ DOCKER-USER → DOCKER chain → ACCEPT → container
│
└─ filter / INPUT ← UFW's rules are here. The packet never comes this way.
By the time the kernel decides whether the packet is “for me” or “passing through”, Docker has already rewritten it to point at the container. So it goes down the FORWARD path, where Docker accepts it, and UFW’s INPUT rules never see it at all.
UFW wasn’t ignoring my rule. The packet just never walked past it.
You can watch this happen yourself:
sudo iptables -t nat -L PREROUTING -n -v # Docker's jump to its DNAT chain
sudo iptables -L FORWARD -n -v --line-numbers
sudo iptables -L DOCKER -n -v # the ACCEPT rules for published ports
How I fixed it
There are a few ways out. Here’s what I tried, from simplest to most control.
1. Don’t publish what doesn’t need to be public
This fixed most of my problem in five minutes. PostgreSQL never needed to be reachable from outside, so instead of -p 5432:5432 I bound it to localhost:
docker run -p 127.0.0.1:5432:5432 postgres:17
Now Docker only creates the DNAT rule for traffic arriving on 127.0.0.1. From the internet, the port is simply closed.
For containers that only talk to each other, I stopped publishing ports altogether and put them on a shared Docker network. They reach each other by container name, and nothing is exposed on the host. Only the reverse proxy publishes 80 and 443.
2. Filter in the DOCKER-USER chain
Sometimes a port really does need to be published, but only to certain IPs. For that, Docker gives you a chain called DOCKER-USER. It runs before Docker’s own accept rules, and Docker promises never to overwrite it.
# only allow the office network to reach the admin app on host port 8080
sudo iptables -I DOCKER-USER -i eth0 \
-p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL \
! -s 203.0.113.0/24 -j DROP
That conntrack --ctorigdstport part was a small trap. By the time a packet reaches DOCKER-USER, DNAT has already happened, so a plain --dport 8080 doesn’t match anymore. The destination port is now the container’s port (80). --ctorigdstport matches the port the client originally asked for.
These rules aren’t saved by UFW, so they need to be persisted (for example with iptables-persistent) or added from a startup script.
3. Let UFW manage FORWARD too
There’s a well-known helper project, ufw-docker, which adds rules to UFW’s after.rules so that UFW also filters forwarded traffic to containers. You then allow container ports with ufw route allow. It works well if you want everything in one place, but it’s one more moving part to understand.
What I didn’t do
I briefly considered setting "iptables": false in Docker’s daemon.json, so Docker stops touching iptables. Don’t. Container networking, outbound NAT and port publishing all depend on those rules. You’d be trading one surprise for several.
What I took away
- UFW only filters traffic to the host. Docker’s published ports are forwarded traffic, so they take a different path.
-p 8080:80means “open to the world”, unless you put an IP in front of it. I now write-p 127.0.0.1:...by default and remove it only on purpose.- Test from the outside.
ufw statusshowed me what I meant to configure.nmapfrom another machine showed me what was actually open.
The funny part: I spent an hour convinced UFW had a bug. It was working perfectly. I just didn’t know where my packets were going.
Discussion
Enjoy!