Docker bypasses UFW: why it happens and how to fix it for good
I set up a fresh server the way most of us do: enable UFW, deny everything, allow SSH. Then I started a container with Docker. From my laptop, the container answered. UFW had never heard of it.
This is a known problem, and Docker’s own documentation says it plainly: Docker and UFW “use firewall rules in ways that make them incompatible with each other”. But reading that is not the same as seeing it on your own server. So I tested it, step by step, on a clean VPS, including the fixes people usually recommend. One of them doesn’t work.
The setup
- A VPS running Debian 13 (trixie)
- UFW 0.36.2, installed from the Debian repositories
- Docker Engine 29.9.3, installed with the official script
In the outputs below, 203.0.113.10 is the server and 198.51.100.7 is my own machine. Both are documentation addresses.
Step 1: a firewall that allows only SSH
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw enable
sudo ufw status verbose
Status: active
Default: deny (incoming), allow (outgoing), disabled (routed)
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
22/tcp (v6) ALLOW IN Anywhere (v6)
Only port 22 is open. That’s what the firewall says, anyway.
Step 2: one container on port 8080
curl -fsSL https://get.docker.com | sudo sh
sudo docker run -d --name test -p 8080:80 nginx
Port 8080 appears nowhere in ufw status. Then, from my own machine:
curl -I --max-time 5 http://203.0.113.10:8080
HTTP/1.1 200 OK
Server: nginx/1.31.8
nginx answers from the Internet. No warning, no error, and ufw status still looks like a locked-down server.
Two more details from the server:
docker info | grep -i firewall
sudo ss -tlnp | grep 8080
Firewall Backend: iptables
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",...))
LISTEN 0 4096 [::]:8080 [::]:* users:(("docker-proxy",...))
Debian 13 uses nftables, but Docker 29 still writes its rules through iptables by default. The behavior is the same as on Ubuntu.
One more thing worth noticing: once Docker was installed, UFW’s status changed from disabled (routed) to deny (routed). It still didn’t stop anything.
Why it happens
When you publish a port with -p 8080:80, Docker adds a DNAT rule that rewrites the destination of incoming packets to the container’s private address. Once rewritten, the packet is no longer addressed to the host itself. It goes through the FORWARD chain, which Docker manages, instead of the INPUT chain, where UFW’s rules live.
UFW isn’t broken. It’s never asked.
Docker’s documentation also warns that “publishing container ports is insecure by default”: a published port is available to the outside world, not just to the host.
What about IPv6?
The ss output showed the port listening on IPv6 too, so I tested it from my machine:
curl -6 -I --max-time 5 "http://[2001:db8::10]:8080"
curl: (28) Connection timed out after 5006 milliseconds
Blocked. To check that UFW was really the reason, I temporarily allowed 8080 in UFW and tried again:
sudo ufw allow 8080/tcp
HTTP/1.1 200 OK
So in this setup, UFW does protect the IPv6 side. Here’s why: the default bridge network is IPv4 only. In that case Docker maps the host’s IPv6 addresses to the container’s IPv4 address through its userland proxy, docker-proxy. That’s a process running on the host, so the traffic goes through INPUT, and UFW sees it.
Don’t rely on this. If you enable IPv6 on your Docker networks, the picture changes, and you should test again.
Remove the test rule:
sudo ufw delete allow 8080/tcp
Fix 1: bind the port to localhost
If the service doesn’t need to be reachable from outside (a database, an admin panel, an app behind a reverse proxy on the same host), publish it on 127.0.0.1 only:
sudo docker rm -f test
sudo docker run -d --name test -p 127.0.0.1:8080:80 nginx
sudo ss -tlnp | grep 8080
curl -I http://127.0.0.1:8080
LISTEN 0 4096 127.0.0.1:8080 0.0.0.0:* users:(("docker-proxy",...))
HTTP/1.1 200 OK
It works locally. From my machine:
curl: (28) Connection timed out after 5003 milliseconds
The IPv6 listener is gone too. In Compose, it’s the same idea:
services:
web:
image: nginx
ports:
- "127.0.0.1:8080:80"
One caveat from Docker’s docs: in releases older than 28.0.0, hosts on the same L2 network segment could still reach ports published on localhost. Keep Docker up to date.
For most setups, this fix is all you need.
Fix 2: filter in the DOCKER-USER chain
Sometimes the port has to stay published, but only for some clients. For that, Docker provides the DOCKER-USER chain. Rules in it are evaluated before Docker’s own forwarding rules.
The rule that looks right and does nothing
This is the version I see most often, and the one I almost used myself:
sudo docker rm -f test
sudo docker run -d --name test -p 8080:80 nginx
sudo iptables -I DOCKER-USER -p tcp --dport 8080 -j DROP
From my machine:
HTTP/1.1 200 OK
Still open. By the time a packet reaches DOCKER-USER, DNAT has already rewritten it. The destination port is now the container’s port, 80, not 8080. The rule never matches.
The rule that works
To match the port the client originally connected to, you need the conntrack extension, as Docker’s documentation explains:
sudo iptables -D DOCKER-USER -p tcp --dport 8080 -j DROP
sudo iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL -j DROP
sudo iptables -L DOCKER-USER -n -v
Chain DOCKER-USER (1 references)
pkts bytes target prot opt in out source destination
0 0 DROP tcp -- * * 0.0.0.0/0 0.0.0.0/0 ctorigdstport 8080 ctdir ORIGINAL
From my machine:
curl: (28) Connection timed out after 5004 milliseconds
Blocked. Docker’s docs note that conntrack matching can cost some performance, which is one more reason to prefer Fix 1 when you can.
Allow one address only
In practice, you usually want to keep the port open for one IP (your office, a VPN, another server). Restrict the rule to the public interface (ens3 on my VPS, check yours with ip a) and exclude the allowed address:
sudo iptables -I DOCKER-USER -i ens3 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 198.51.100.7 -j DROP
From 198.51.100.7, the port answers 200 OK. From anywhere else, the connection times out.
The reboot trap
Rules added with the iptables command live in memory. I rebooted the server, started the container again, and checked:
sudo iptables -L DOCKER-USER -n -v
Chain DOCKER-USER (1 references)
pkts bytes target prot opt in out source destination
Empty. And from my machine:
HTTP/1.1 200 OK
The port was open again, with nothing to tell me.
Making the rule permanent
Since UFW already manages the firewall, the cleanest place for the rule is UFW’s own /etc/ufw/after.rules, which UFW loads on every reload and every boot. Back it up, then add a block at the end:
sudo cp /etc/ufw/after.rules /etc/ufw/after.rules.bak
sudo tee -a /etc/ufw/after.rules > /dev/null <<'EOF'
# Docker: port 8080 published by Docker, reachable only from one IP
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i ens3 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 198.51.100.7 -j DROP
COMMIT
EOF
sudo ufw reload
After a reboot:
Status: active
...
Chain DOCKER-USER (1 references)
pkts bytes target prot opt in out source destination
0 0 DROP tcp -- ens3 * !198.51.100.7 0.0.0.0/0 ctorigdstport 8080 ctdir ORIGINAL
The rule is still there, and the port is still filtered. Running ufw reload again replaces the rule instead of adding a duplicate.
Two warnings:
- Check that UFW is still
activeafter editingafter.rules. A syntax error there could leave you with no firewall at all. - Don’t reach for
iptables-persistentto save the rule. It conflicts with UFW, and security benchmarks such as CIS recommend not running both.
If you have many containers to manage, the ufw-docker project automates this approach in after.rules and adds commands to allow or deny container ports. Read the script before you run it.
What not to do
Many older guides suggest setting "iptables": false in /etc/docker/daemon.json. Docker’s documentation says this is not appropriate for most users and is likely to break container networking.
What I’d do on a new server
- Publish internal services on
127.0.0.1only. Databases, admin panels and anything behind a reverse proxy. - For a port that must stay public but restricted, use
DOCKER-USERwith--ctorigdstport, never--dport. - Put those rules in
/etc/ufw/after.rules, and reboot once to check they survive. - Test from outside the server.
ufw statusonly shows UFW’s own rules, not what Docker opened.
What’s next
Docker 29 added an opt-in nftables backend, and Docker plans to make it the default in a future release. If you switch, rules in the iptables DOCKER-USER chain will need to be migrated. I haven’t tested that backend yet, so everything above applies to the default iptables backend.
I ran into this kind of problem often enough at work that I started building Staunch, a tool that checks servers for issues like this one and tells you how to fix them. But you don’t need any tool for this: curl from another machine is enough to find out.
Sources
- Docker docs: Packet filtering and firewalls
- Docker docs: Docker with iptables
- Docker docs: Port publishing and mapping
- Docker docs: Docker with nftables
- chaifeng/ufw-docker