SSD Nodes Learn
Guides Matt ConnorBy Matt Connor

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 the PREROUTING chain of the kernel's nat table. 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-alpine

ufw 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 OK

The container answers. Add an explicit deny rule and test again:

sudo ufw deny 8080/tcp

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

  1. PREROUTING runs first. Rules here may rewrite the packet's destination, and Docker's rule for a published port does exactly that.
  2. The routing decision comes next. A packet addressed to the host itself goes to the INPUT chain. A packet addressed to any other machine goes to the FORWARD chain.
  3. UFW's rules live in INPUT. Docker's rules live in FORWARD.

Look at Docker's rule for the container you just started:

sudo iptables -t nat -L DOCKER -n
Chain 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:80

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

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-alpine

Or 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 a packet from the internet can never legitimately carry that destination, so the kernel drops it before any firewall rule runs. The port is reachable from the host, and from nothing else. Verify the binding:

sudo ss -tlnp | grep 8080

You 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:

ToolUFW rule generator

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, and Docker never writes rules into it. The chain exists for yours, and Docker leaves its contents alone across daemon restarts.

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 DROP

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

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
COMMIT

Then 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 pull images, reach package mirrors, or call any external API (application programming interface). The DNAT rules are what make -p work at all, so published ports stop working entirely. The isolation rules that keep separate Compose networks apart go away too. 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 describes the setting as one for people who intend to do exactly that. 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 8080

Since 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/.

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 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.
  • 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-USER, 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 the PREROUTING chain, 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, 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 all 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 published ports stop working because the DNAT rules are gone. 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 needs the same DOCKER-USER rule mirrored with ip6tables. 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.