← blog · October 16, 2026

Why Published Docker Ports Bypass Your Firewall: UFW, the DOCKER-USER Chain and the Bind Address

UFW reports that every incoming connection is denied, yet a port published by Docker can still be open to the internet. The path a packet really takes, closing ports with the bind address, filtering in DOCKER-USER, IPv6, and why the test has to run from outside.

There is a pattern that shows up again and again on single server setups. UFW gets enabled, the default policy denies incoming traffic, and only SSH and HTTPS are allowed through. Later a database container is started with -p 5432:5432 because some local tool needed to reach it. ufw status still lists the same two rules, yet the database port is now reachable from anywhere on the internet. Usually a scan report or an unexpected login attempt in the database log is what brings it to light. UFW is not broken, and Docker did not leave a hole by accident. The two simply act at different points along the packet's path.

sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw allow 443/tcp
sudo ufw enable
docker run -d --name db -e POSTGRES_PASSWORD=example -p 5432:5432 postgres:17

After these five commands, 5432 is open to the world.

The path a packet takes

UFW writes its rules into the iptables INPUT chain, which sees packets addressed to the machine itself. When you publish a port, Docker adds a destination NAT rule to the nat table. Before any routing decision is made, in PREROUTING, the packet's destination is rewritten to the container's address. The destination is no longer the host but a container on a bridge network, so the packet never visits INPUT. It travels through FORWARD instead, where Docker's own rules accept traffic for published ports. UFW's deny rule never gets a say.

Docker's documentation says so plainly: Docker and UFW use firewall rules in ways that are incompatible with each other, and traffic to a published port is diverted before it reaches the UFW settings. Kubernetes NodePort services can slip past a node's host firewall in a similar way. Same mechanism, different tool.

First line of defence: do not publish

The most reliable fix is not publishing the port at all. Services in the same Compose file share a network and reach each other by service name, so the application connects to the database at db:5432 without any ports entry. A ports line on a database, a cache or a queue that only other containers talk to is a warning sign in itself.

If you occasionally need to reach that database from your own machine for administration, an SSH tunnel usually does the job without publishing anything.

Second line: the bind address

If a port must be published and the only consumer is a reverse proxy on the same host, bind it to the loopback address:

services:
  web:
    image: nginx:1.27
    ports:
      - "127.0.0.1:8080:80"
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./db_password.txt

In this file web is reachable only from the host itself, and db is not published at all. Writing 8080:80 without an address binds to every interface, which is Docker's default. docker ps shows it as 0.0.0.0:8080->80/tcp, often next to [::]:8080->80/tcp. The ip setting in /etc/docker/daemon.json changes that default, so ports published without an explicit address bind there instead. It is a good seatbelt against a forgotten ports line, but it is also an assumption sitting in a config file that can get lost when the host is rebuilt. Writing the address in the Compose file makes the intent visible to whoever reads it next.

Third line: the DOCKER-USER chain

When a port has to be public but only for certain sources, the rule belongs in the DOCKER-USER chain. Docker creates this chain and evaluates it inside FORWARD ahead of its own rules, so anything you put there applies to every published port.

The trap here is that by the time a packet reaches this chain, DNAT has already happened. The destination address is the container's, and the destination port is the one inside the container. If you write --dport 8080 and the container listens on 80 internally, the rule matches nothing. To match on the original destination, use the conntrack extension:

sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 ! --ctorigsrc 203.0.113.0/24 -j DROP

This drops connections that arrive on the external interface (eth0), were originally aimed at port 8080, and come from outside the given block. The -i eth0 part matters. Return traffic for connections the containers opened themselves, and traffic between containers, never arrives on that interface, so the rule leaves it alone.

The second trap is persistence. A rule added with the iptables command is gone after a reboot. Saving the full output of iptables-save looks convenient, but it also captures Docker's dynamic rules tied to container addresses, and stale rules restored at boot get tangled with the ones Docker builds again. Persist only your own DOCKER-USER lines. If you use UFW, their natural home is UFW's after.rules file. Community scripts such as ufw-docker do exactly that and hook DOCKER-USER into UFW's forwarding rules, so you can keep managing access with ufw route commands.

What not to do: switch off Docker's iptables handling

Putting "iptables": false in daemon.json to keep Docker away from the firewall looks tidy at first glance. Docker's documentation warns that it is likely to break container networking. The NAT that containers need to reach the outside is not set up, published ports stop working, and the isolation rules between networks disappear. Rebuilding all of that by hand means taking ownership of an internal detail that can change in any Docker release. Our advice is simple: leave that setting alone and do your filtering in DOCKER-USER.

Check IPv6 separately

An iptables rule written for IPv4 does nothing for IPv6. If Docker runs with IPv6, the same chain exists on the ip6tables side and the rule has to be written there too. UFW also keeps separate rules for each protocol. Closing a port on IPv4 while leaving it open on IPv6 is one of the most commonly missed cases, especially when nobody remembers that the server has an IPv6 address at all.

Test from outside

Testing a firewall from the server itself is misleading. A connection from the machine to its own public address goes through different chains and never meets the rules an external packet would. The real test runs from a machine on a different network:

nmap -Pn -p 22,80,443,5432,6379,8080 203.0.113.10

On the server, check two things regularly. Every port in docker ps that starts with 0.0.0.0 or [::] should be a deliberate decision, and ss -ltnp should not show a listener you did not expect. Doing this once is not enough. Repeat it whenever a Compose file is added or an image is updated, because that is exactly when the hole tends to appear. Better still, run the external scan on a schedule and compare the result with the list of ports you expect.

When you do not need all of this

If the server sits behind a network level firewall run by the provider, such as cloud security groups, and that firewall only lets the ports you need through, a published port cannot reach the internet anyway. That layer is then your main defence and the host rules become a second one. Writing the bind address correctly is still a free precaution: if the outer firewall changes one day, a single mistaken rule will not expose every database you run.

Containers using network_mode: host behave differently. They share the host's network directly, there is no DNAT, and their traffic passes through INPUT, so UFW rules apply to them the way you would expect. That alone is not a reason to choose host networking, but it explains why the two modes relate to the firewall so differently.

[ ← Back to burak.sh ]