ShipOps

Docker · Security

Docker quietly bypasses UFW

Here is a firewall doing nothing at all, while reporting that it is working.

$ sudo ufw status
Status: active

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW       Anywhere
80/tcp                     ALLOW       Anywhere
443/tcp                    ALLOW       Anywhere

$ docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=hunter2 postgres:16

$ # from a completely different machine:
$ psql -h your-server-ip -U postgres
Password for user postgres:
psql (16.14)
postgres=#

UFW's default policy is deny incoming. Port 5432 appears nowhere in its rules. The database is on the internet anyway.

Why

UFW is a front-end for iptables. Its rules land in chains hooked into INPUT — the path for traffic addressed to the host itself.

Traffic to a published container port is not addressed to the host. Docker sets up a DNAT rule that rewrites the destination to the container's address on the bridge network, and the packet is then routed to it. Routed traffic traverses FORWARD, not INPUT — and Docker has inserted its own ACCEPT rules there.

So UFW's rules are never consulted. They are not overridden or outranked; the packets simply never pass through the chain UFW is watching.

This is not a bug, and Docker is not going to change it — the DNAT is how published ports work at all. But it does mean the mental model most people hold, that UFW is a switch controlling what the server accepts, is wrong on any machine running Docker.

Check whether you are exposed right now

Do this from another machine. Testing from the server itself proves nothing, because loopback traffic takes a different path entirely.

$ # what is actually published, and on which interface
$ docker ps --format '{{.Names}}\t{{.Ports}}'
api        0.0.0.0:3000->3000/tcp
db         0.0.0.0:5432->5432/tcp      <- exposed
cache      127.0.0.1:6379->6379/tcp    <- fine

$ # then, from your laptop
$ nc -zv your-server-ip 5432

0.0.0.0: means every interface, including the public one. 127.0.0.1: means loopback only, which is what you usually want.

If a database answers, assume it has been found. Scanners sweep the whole IPv4 space continuously, and an exposed Postgres or Redis with a weak password is typically discovered within hours, not weeks.

The fix, in order of preference

1. Do not publish the port

Containers on the same Docker network reach each other by service name without publishing anything. Your application talks to postgres:5432 across the internal network; the host never needs to expose it.

In practice only your reverse proxy should publish ports. Everything else stays internal:

services:
  caddy:
    ports:
      - "80:80"
      - "443:443"      # the only thing on the internet
  postgres:
    # no ports: block at all. Reachable at postgres:5432
    # from other containers, and from nowhere else.
    networks: [internal]

2. If you need local access, bind to loopback

Publishing to 127.0.0.1 gives you a port you can reach over an SSH tunnel while remaining unreachable from outside:

$ # in compose
    ports:
      - "127.0.0.1:5432:5432"

$ # then from your laptop
$ ssh -L 5432:localhost:5432 you@your-server
$ psql -h localhost -U app

This is the answer to "I just need to look at the production database for a minute". That minute is how databases end up on Shodan.

3. Use DOCKER-USER for rules that must apply

Docker provides one chain it will never overwrite, evaluated before its own rules in FORWARD. Rules you put there do apply to container traffic:

$ sudo iptables -I DOCKER-USER -i eth0 -p tcp --dport 5432 -j DROP

It works, but you are now maintaining two firewalls with different syntaxes and no shared view of what is allowed. Reach for it when you genuinely must publish something and restrict it by source; do not build your whole security posture here.

4. Best of all: a firewall outside the host

Cloud provider firewalls — AWS security groups, Hetzner Cloud Firewall, DigitalOcean Cloud Firewalls — filter before traffic reaches the machine. Nothing running on the box, Docker included, can punch through them.

This is the one genuine advantage of the cloud firewall over UFW, and it is worth using even when you also run UFW. A port you did not open in the security group is shut, whatever a container thinks it published.

The takeaway

On a Docker host, ufw status tells you what the host accepts. It says nothing about what your containers have published, and reading it as a complete picture is how people end up with production databases open to the internet while believing they are firewalled.

Related: the backup that restores perfectly and contains nothing, for the other kind of check that looks like it works and does not.

The habit worth building is simple: docker ps is part of your firewall audit. If a line says 0.0.0.0: and it is not your reverse proxy, that is a finding.