← All Posts

The Firewall Rule That Wasn't: Docker, ufw, and a Silent Port Leak

ufw was configured correctly. The port was still open to the internet. Here's the iptables ordering issue that caused it, and how to actually close it.

For three minor versions of a production booking platform, this line sat quietly in docker-compose.yml:

backend:
  ports:
    - "8000:8000"

It looked harmless. The server’s firewall (ufw) was configured to allow only SSH, HTTP, and HTTPS:

22/tcp   ALLOW
80/tcp   ALLOW
443/tcp  ALLOW

By every reasonable reading of that ruleset, port 8000 — the raw Django API, with no Nginx in front of it — should have been unreachable from the internet. It wasn’t. The Django backend was directly accessible on the server’s public IP, completely bypassing Nginx’s rate limiting, security headers, and CSP.

Why ufw Didn’t Help

ufw is a frontend for iptables, but it isn’t the only thing writing iptables rules. Docker writes its own rules — directly into the DOCKER-USER and DOCKER chains — whenever a container publishes a port with ports:.

The critical detail: Docker’s chains are processed before ufw’s chains in the default iptables rule ordering. When a packet arrives on port 8000, the kernel hits Docker’s port-forwarding rule and routes it straight to the container — before ufw’s “deny by default” policy ever gets evaluated.

From ufw’s perspective, everything is fine. ufw status shows exactly the three rules above. But iptables -L DOCKER-USER (or -t nat -L DOCKER) shows Docker has already inserted a rule that forwards external traffic on 8000 to the container’s internal IP — and that rule sits earlier in the chain.

This is documented Docker behaviour, not a bug — but it means “my firewall blocks everything except 80/443” is false the moment any container publishes a port, regardless of what ufw status reports.

Finding It

The way this surfaces in practice is mundane: a port scan, or a stray request in the access logs hitting /api/admin/login/ directly — not through /api/admin/login/ via the Nginx proxy, but on :8000 — with none of the response headers Nginx normally adds (no Strict-Transport-Security, no CSP, none of the add_header directives from nginx/production.conf).

If you want to check whether this affects you, the relevant commands are:

# What ufw thinks is allowed
sudo ufw status verbose

# What's actually forwarded — look for rules
# referencing your container's published ports
sudo iptables -t nat -L DOCKER -n
sudo iptables -L DOCKER-USER -n

If a published container port shows up in the NAT table, it’s reachable regardless of ufw.

The Fix

The fix isn’t a ufw rule — no ufw rule can win against a DOCKER chain rule that runs first. The fix is to not publish the port to all interfaces in the first place.

Option 1 — don’t publish at all. If the only thing that needs to reach the backend is another container (e.g. an Nginx reverse-proxy container on the same Docker network), it doesn’t need a ports: entry at all. Docker’s internal network already lets containers reach each other by service name:

backend:
  # no ports: block — only reachable from other containers
  # on the same Docker network, via http://backend:8000
  expose:
    - "8000"

frontend:
  ports:
    - "127.0.0.1:5500:80"   # only the host's Nginx can reach this

Option 2 — bind to loopback only, if a host process (not a container) needs access:

backend:
  ports:
    - "127.0.0.1:8000:8000"   # not 0.0.0.0:8000:8000

The 127.0.0.1: prefix tells Docker to bind only to the loopback interface. Traffic from outside the host can’t reach it — there’s no 0.0.0.0 rule for iptables to insert in the first place.

What the Full Boundary Looks Like

For a typical Compose stack with a Postgres database, a Redis cache, a Django backend, and an Nginx-fronted static frontend, the end state is: the database and cache have no ports: block at all — internal-network-only. The backend either has no ports: block (internal only, reached via the frontend container’s Nginx config) or is bound to 127.0.0.1. The frontend is the only thing with an externally-reachable port, and even that binds to 127.0.0.1:5500 so that the host’s Nginx — running outside Docker, the thing ufw allow 80,443 actually governs — is the sole entry point.

With that topology, ufw allow 22,80,443 is finally an accurate description of what’s reachable. Every container sits behind a layer that ufw actually controls.

The Broader Lesson

“My firewall is configured correctly” and “my firewall governs all traffic to this host” are different claims, and Docker is one of several things that can make the first true while the second is false. The same class of issue exists with anything that manipulates iptables directly — some VPN clients, some Kubernetes CNI plugins, libvirt’s default networking.

The practical takeaway: after configuring ufw on a host running Docker, don’t just check ufw status. Check what’s actually in the iptables NAT and filter tables, or — more simply — run a port scan against the host’s public IP from an external machine and compare it against what you expect to be open. The firewall’s intent and the kernel’s actual behaviour are two different things, and only one of them matters.