Field notes
UFW says "deny incoming". Your Docker containers are on the internet anyway.
I found this during a security audit of my own server. UFW was active with a default deny policy and every application port blocked. And yet a Postgres and a Redis container had been reachable from the internet for six weeks. Here is why, how to check your own box in ten seconds, and the fixes.
The symptom
$ sudo ufw status
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
80/tcp ALLOW Anywhere
443/tcp ALLOW Anywhere
Everything else should be dropped. Now the containers:
$ docker ps --format '{{.Names}}\t{{.Ports}}'
jobsearch-postgres 0.0.0.0:5544->5432/tcp
jobsearch-redis 0.0.0.0:6390->6379/tcp
web-admin 0.0.0.0:80->80/tcp
From another machine, nc -vz <server> 5544 connects. UFW never saw it.
Why UFW loses
UFW is a front end for iptables that works on the INPUT chain — traffic addressed to the host itself. A published container port is not traffic to the host: Docker adds a DNAT rule in the nat table's PREROUTING chain that rewrites the destination to the container's address, and the packet then flows through the FORWARD chain, where Docker manages its own rules and puts them before anything UFW installs. Docker's documentation is explicit about it: rules in INPUT do not apply to published ports, and the supported place for your own filtering is the DOCKER-USER chain.
So ufw deny 5544 is a rule for a packet that never reaches the chain the rule lives in.
Check your own server (10 seconds)
# 1. anything published on all interfaces?
docker ps --format '{{.Names}}\t{{.Ports}}' | grep '0.0.0.0'
# 2. what does the DOCKER-USER chain say? (empty = "RETURN", i.e. allow everything)
sudo iptables -L DOCKER-USER -n -v --line-numbers
# 3. the only test that counts: from OUTSIDE the box
nc -vz your.server.ip 5544
ss -tlnp on the host is not enough: a published port shows up as docker-proxy listening on 0.0.0.0, which looks like any other service and tells you nothing about what the firewall does with it.
In my case the mechanism was confirmed by an nginx container publishing port 80: its access log held 135,000 requests from the internet over four months. Port 80 is meant to be public, but it proved the path — and the same path applied to the database and the cache.
Fix 1 — don't publish on 0.0.0.0 (do this first)
A database or a cache should almost never have a public port at all. Bind to loopback:
# docker-compose.yml
services:
postgres:
ports:
- "127.0.0.1:5544:5432"
redis:
ports:
- "127.0.0.1:6390:6379"
Or drop the ports: entry entirely and let the other containers reach it over the compose network by service name — that is what the network is for. Other tools that ship with an open port by default deserve the same treatment: Ollama listens on 0.0.0.0:11434 with no authentication unless you set OLLAMA_HOST=127.0.0.1:11434.
Fix 2 — filter in DOCKER-USER
For ports that must be published but not to the whole world, add rules to the chain Docker leaves for you. ext_if is your public interface (ip route | grep default):
# allow an office / VPN range, drop everyone else, for every published port
sudo iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.0/24 -m conntrack --ctstate NEW -j DROP
# or per port
sudo iptables -I DOCKER-USER -i eth0 -p tcp --dport 6390 -m conntrack --ctstate NEW -j DROP
Rules in DOCKER-USER are evaluated before Docker's own, and Docker never flushes the chain. They are not persistent by themselves: put them in /etc/ufw/after.rules (UFW loads it) or use iptables-persistent.
Fix 3 — ufw-docker
If you want to keep thinking in UFW terms, ufw-docker installs a DOCKER-USER block in /etc/ufw/after.rules and gives you commands such as ufw-docker allow web-admin 80/tcp. It is the least surprising option on a server where several people run ufw allow and expect it to mean something.
What I changed
- The two containers were already stopped when I found this. Before they come back, their compose file publishes on
127.0.0.1— or not at all, since only other containers ever talk to them. - The only publication left on
0.0.0.0is nginx, which is public by design. - Still on the list: Ollama and my development backend run on the host, not in Docker, so UFW does protect them today — but binding them to loopback removes the dependency on a firewall rule being right.
- Every audit checklist I use now starts with
docker psand theDOCKER-USERchain, beforeufw status— becauseufw statusis the one that lies.
The uncomfortable footnote: a Redis without a password reachable from the internet is not "some data exposure". Redis can be told to write its dump file anywhere — ~/.ssh/authorized_keys, a cron directory — which is remote code execution. Check the six-week window in your logs, not just the fix.