Docker bypasses UFW

ufw status says only 22, 80 and 443 are open. A port scan from home finds your database on 5432 anyway. Your firewall isn't broken. Docker routes around it.

Why it happens

UFW filters traffic addressed to the server itself, in the INPUT chain. When you publish a port with -p 8080:80 or ports: in Compose, Docker adds a NAT rule that rewrites the destination to the container's address. From then on the packet is being forwarded to another host, the container, so it goes through the FORWARD chain instead. UFW's rules for port 8080 never see it.

Docker leaves one place for your own rules on that path: the DOCKER-USER chain, which runs before Docker's own forwarding rules.

Check from outside first

Checking from the server itself proves nothing: local traffic doesn't take the same path. From another machine:

nmap -Pn -p 1-65535 your.server.example

Then compare with what Docker publishes on the server:

docker ps --format '{{.Names}}\t{{.Ports}}'

Anything shown as 0.0.0.0:PORT-> or [::]:PORT-> is reachable from the internet unless something in DOCKER-USER stops it.

Fix 1: don't publish it at all

The cleanest fix. If only other containers need the service (a database, a cache), remove the ports: entry and let them talk over the Compose network by service name. If a process on the host needs it, publish it on loopback only:

services:
  db:
    image: postgres:17
    ports:
      - "127.0.0.1:5432:5432"

Recreate the container afterwards (docker compose up -d); a restart alone keeps the old binding.

Fix 2: block it in DOCKER-USER

For ports that a platform publishes for you (Coolify, Portainer, a reverse proxy you can't reconfigure), drop the traffic in DOCKER-USER. Because the destination has already been rewritten by the time the packet gets there, match on the port the client originally asked for, through conntrack:

# eth0 = your public interface (check with: ip route get 1.1.1.1)
iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 -j DROP

# allow one address in first, if you still need it from somewhere
iptables -I DOCKER-USER -i eth0 -s 203.0.113.7 -p tcp -m conntrack --ctorigdstport 8080 -j RETURN

-I inserts at the top, so add the allow rule after the drop rule and it ends up above it. A plain --dport 8080 rule usually does nothing here: after the rewrite, the destination port is the container's port, not the published one.

These rules don't survive a reboot. After a reboot the chain comes back empty when Docker starts. Re-apply them from a small systemd unit that runs After=docker.service. Don't install iptables-persistent to save them on Ubuntu: it conflicts with the ufw package and removes it.

Fix 3: turn off Docker's iptables (usually not)

Setting "iptables": false in /etc/docker/daemon.json stops Docker from touching the firewall at all. It also stops it from setting up NAT, so containers can lose outbound internet and published ports stop working until you write all those rules yourself. Only do this if you mean to manage the whole ruleset by hand.

Afterwards

Run the scan from outside again. The port should show as filtered, while the services you meant to expose still answer. If you lock yourself out while changing firewall rules over SSH, you need a way back in. Your provider's web console works, or a change that undoes itself unless you confirm it.

Stenvakt does this check for you. Its Review lists every port Docker publishes that nothing in DOCKER-USER blocks. One fix adds the DOCKER-USER drops (with an allowlist) and the systemd unit that keeps them. It runs in safe mode: if the change cuts off your SSH, the server rolls it back by itself. It's a Mac app and isn't out yet: get early access.

Written 26 October 2026. Assumes Docker's default iptables backend and UFW on Ubuntu or Debian.