Docker bypasses UFW: why and how to fix it
Docker publishes container ports with iptables rules that skip UFW, so a denied port still answers the internet. See the mechanism and the fixes that work.
Why Docker bypasses UFW
Docker bypasses UFW because published container ports never pass through the firewall rules UFW manages. When you run docker run -p 8080:80, Docker writes a DNAT (destination network address translation) rule into its own DOCKER chain in the kernel's nat table, which the PREROUTING chain jumps to. That rule rewrites each packet's destination to the container's private address before the kernel decides where the packet is going. The rewritten packet is then forwarded into the container through the FORWARD chain, which Docker controls. UFW's rules live in the INPUT chain, and the packet never enters it. So ufw status shows default deny, sudo ufw deny 8080 reports success, and port 8080 still answers the whole internet.
This is not a Docker bug, and UFW is not broken. Both tools program the same kernel firewall. Docker's rules simply act at an earlier point on the packet's path, so UFW is never asked. This guide demonstrates the bypass, explains the mechanism, and then covers the two fixes that work: publishing ports on 127.0.0.1, and filtering in the DOCKER-USER chain. If UFW itself is new to you, set it up with the UFW firewall basics guide first, because a default-deny firewall is still the right base for everything else on the server.
See the bypass on your own server
Start from a VPS where UFW is active with a default deny policy for incoming traffic. Run a web container with a published port:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose shows Default: deny (incoming), allow (outgoing) and no rule for port 8080. By the firewall's own report, the port is closed. Now test from a different machine, not from the server itself:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKThe container answers. Add an explicit deny rule and test again:
sudo ufw deny 8080/tcpThe port still answers, because the deny rule sits in a chain the packet never visits. UFW did not fail. It was never consulted. This is also why the problem hides so well: no error is printed anywhere, the deploy works, and the firewall status output looks exactly like a healthy locked-down server.
The mechanism: PREROUTING runs before INPUT
The kernel processes an incoming packet in a fixed order, and the whole problem lives in that order.
PREROUTINGruns first. Rules reached from here may rewrite the packet's destination, and Docker's rule for a published port, in theDOCKERchain thatPREROUTINGjumps to, does exactly that.- The routing decision comes next. A packet addressed to the host itself goes to the
INPUTchain. A packet addressed to any other machine goes to theFORWARDchain. - UFW's rules live in
INPUT. Docker's rules live inFORWARD.
Look at Docker's rule for the container you just started:
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80The DNAT line is the whole story. Any packet arriving for port 8080 gets its destination rewritten to 172.17.0.2:80, the container's address on Docker's private bridge network. After the rewrite the packet is no longer addressed to the host, so the routing decision sends it down the FORWARD path, where Docker has already added rules that accept traffic into its own networks. Your deny 8080/tcp rule waits in INPUT for a packet that never comes.
On Ubuntu 24.04 the iptables command is a front end over nftables, but the chain order and the outcome are identical. UFW and Docker both write into the same kernel packet pipeline, and Docker's entry point is earlier. None of this is specific to UFW: firewalld on a Rocky or AlmaLinux VPS filters at the same point in that pipeline and is stepped around by the same DNAT rule, so the fixes below are what you reach for there too.
The everyday fix: publish ports on 127.0.0.1
Most containers never needed to be public in the first place. A database, an app server behind a reverse proxy, an admin panel, a metrics endpoint: none of these should answer the internet directly. Publish them on the loopback address:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineOr in a Compose file:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"This works because Docker's DNAT rule now matches only packets addressed to 127.0.0.1, and on Docker Engine 28.0.0 and later Docker also adds a rule in the raw table's PREROUTING chain that drops any packet for that loopback port arriving on an interface other than loopback. On those releases the port is reachable from the host, and from nothing else. On releases older than 28.0.0, Docker documents that hosts within the same L2 segment, for example machines on the same network switch, can still reach ports published to localhost, so check docker version and upgrade before you rely on this. Verify the binding:
sudo ss -tlnp | grep 8080You want 127.0.0.1:8080 in the output, not 0.0.0.0:8080 or [::]:8080. Then confirm from another machine that curl http://your-vps-ip:8080/ is refused.
For the services that should face the internet, run one reverse proxy that owns ports 80 and 443 and routes by hostname, and publish nothing else. That is the pattern the Traefik reverse proxy guide builds, and it is how a self-hosted app like Nextcloud on a VPS stays unreachable except through its proxy. How ports: entries are declared, and the rest of the Compose workflow, is covered in the Docker Compose basics guide.
With every internal container on loopback, UFW is back to doing its normal job: guarding the ports the host itself serves. Build that rule set here, then run the commands in order:
Real filtering: the DOCKER-USER chain
Sometimes a container port must stay published to the network but restricted, for example a database replica port that only one office address may reach. For that, Docker provides the DOCKER-USER chain. Every packet heading to any container passes through DOCKER-USER before Docker's own accept rules. The chain exists for your rules, and Docker does not delete or modify them, including across daemon restarts.
This chain belongs to Docker's default iptables firewall backend. Docker Engine 29 added an experimental nftables backend that you have to switch on with the firewall-backend daemon option, and in it there is no DOCKER-USER chain: custom filtering goes in a separate nftables table of your own, with a base chain on the forward hook. If you have not changed that option, you are on iptables and everything below applies. The difference between the two tools is covered in the iptables vs nftables guide.
One trap before the command: by the time a packet reaches DOCKER-USER, the DNAT rewrite has already happened. The packet's destination port is the container port (80 in our example), not the published port (8080). A rule matching --dport 8080 therefore matches nothing. The reliable way is to match the port the client originally dialled, which the kernel's connection tracker remembers:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPRead it as: for packets that entered on eth0 and belong to a connection whose original destination port was 8080, drop everything not sent from 10.0.0.10. The --ctdir ORIGINAL match limits the rule to the client-to-container direction, so reply packets are not caught by mistake. Replace eth0 with your public interface; ip route | grep default names it. Test it the same way as before: curl from the allowed address succeeds, and from anywhere else the connection times out. That hang is the signature of a DROP rule doing its job rather than a port with nothing behind it, and the difference between a refused connection and one that times out is the fastest way to tell a filtered port from a service that simply is not listening.
Rules added with the iptables command disappear at reboot. Since UFW already manages this firewall, the clean place to persist them is /etc/ufw/after.rules. Append a block at the end of the file:
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMITThen run sudo ufw reload. UFW replays that file on every reload and every boot, so your container filtering now lives in the same place as the rest of your firewall, and it survives both a reboot and a Docker upgrade.
Why you should not disable Docker's iptables integration
Older answers to this problem suggest setting { "iptables": false } in /etc/docker/daemon.json. Do not. Docker's firewall rules do much more than publish ports. The masquerade rule is what gives containers outbound internet access through the host's address, so with the integration off, containers cannot reach package mirrors or call any external API (application programming interface). The DNAT and isolation rules go away too, and Docker's documentation warns that with no replacement rules, the containers' ports become accessible to hosts on the local network. You would fix the bypass by breaking container networking, and every one of those rules would become yours to write and maintain by hand. Docker's own documentation calls the setting not appropriate for most users and describes creating the rules after the fact as extremely involved. The DOCKER-USER chain exists precisely so nobody needs this switch.
The IPv6 side of the same problem
First check what the published port looks like on IPv6:
sudo ss -tlnp | grep 8080Since Docker Engine 27, Docker manages ip6tables by default. On a Docker network with IPv6 enabled, a published port gets the same DNAT treatment in the IPv6 tables, so the same bypass exists there and the same fix applies: the DOCKER-USER chain also exists in ip6tables, so mirror your rule with sudo ip6tables -I DOCKER-USER ... and test from outside with curl against your server's public IPv6 address, for example curl -6 http://[2001:db8:2a::1]:8080/. That holds on Docker's default iptables backend; on the experimental nftables backend there is no DOCKER-USER chain on either stack, and the IPv6 filtering goes in your own ip6 nftables table instead.
On a network without IPv6, IPv6 clients are handled instead by docker-proxy, a normal user-space process that listens on [::]:8080 and forwards the traffic into the container over IPv4. Traffic to a host process does go through INPUT, so UFW can filter that path, but only when UFW is managing IPv6 at all. Whether it is, and the other ways an IPv6 gap opens on a VPS, is the subject of the UFW and IPv6 guide.
Publishing on loopback sidesteps the whole question: -p 127.0.0.1:8080:80 binds IPv4 loopback only, so there is no IPv6 listener, and on Docker Engine 28.0.0 and later there is nothing to reach from outside on either stack.
The pattern that holds up
- Publish every internal port on
127.0.0.1, so it is never exposed in the first place, on Docker Engine 28.0.0 or later (older releases let hosts on the same L2 segment reach it). - Give the public side to one reverse proxy that owns ports 80 and 443.
- Keep UFW default deny for the host, allowing SSH and the proxy ports.
- Filter genuinely public container ports in
DOCKER-USERon Docker's default iptables backend, matched on the original destination port, persisted in/etc/ufw/after.rules. - Leave Docker's iptables integration on.
Set up once, this removes the surprise: ufw status describes the host, and DOCKER-USER describes the containers. Nothing is published by accident, and the next docker run -p you type exposes exactly what you meant it to.
FAQ
Why can I reach my Docker container when UFW blocks the port?
Because Docker publishes the port with a DNAT rule in its DOCKER chain of the nat table, reached from PREROUTING, which rewrites the packet's destination to the container's address before any filtering happens. The packet then travels the FORWARD path, and UFW's rules sit in INPUT, a chain the packet never enters. The firewall is never consulted, so its deny rules have no effect on published container ports.
How do I make UFW block Docker's published ports?
UFW itself cannot, because its rules are in the wrong chain. Either stop exposing the port, by publishing it as 127.0.0.1:8080:80 so only the host can reach it on Docker Engine 28.0.0 and later, or filter in the DOCKER-USER chain with an iptables rule that matches the original destination port through conntrack. Persist that rule in /etc/ufw/after.rules so it survives reboots and ufw reload.
Should I set "iptables": false in Docker's daemon.json?
No. That setting removes most of Docker's firewall and NAT rules, which breaks far more than the bypass. Containers lose outbound internet access because the masquerade rule is gone, and Docker warns that without replacement rules, container ports become accessible to hosts on the local network. Use loopback publishing and the DOCKER-USER chain instead; they fix the exposure without breaking container networking.
Does Docker bypass UFW on IPv6 too?
On Docker Engine 27 and later, ip6tables management is on by default, so a port published on an IPv6-enabled Docker network is rewritten around UFW exactly as on IPv4, and on Docker's default iptables backend it needs the same DOCKER-USER rule mirrored with ip6tables. On the experimental nftables backend from Engine 29 there is no DOCKER-USER chain, so that filtering goes in your own nftables table. On networks without IPv6, the docker-proxy process listens on [::] and that traffic does pass through INPUT, where UFW can filter it if UFW manages IPv6. Publishing on 127.0.0.1 avoids both cases, because nothing listens on IPv6 at all.