← all posts

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:80 means “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 status showed me what I meant to configure. nmap from 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.

References

Discussion

Enjoy!