A very common surprise: you enable UFW, allow only SSH, HTTP and HTTPS, then run docker run -p 5432:5432 postgres, and the database is reachable from the whole internet, despite UFW saying port 5432 is closed. This isn’t a bug in UFW: Docker inserts its own iptables rules that take effect before UFW’s rules. Here’s why, how to check, and how to fix it.
Why it happens
When you publish a port (-p 8080:80, or ports: in Compose), Docker adds DNAT rules in the nat table, and forwarding rules in its own chains (DOCKER, DOCKER-USER, DOCKER-FORWARD). Traffic to a published port is forwarded to the container’s IP. It goes through the FORWARD chain, not INPUT, where UFW’s ufw allow and ufw deny rules live. So UFW never sees it.
Check what’s exposed
sudo ss -tlnp | grep docker-proxy
docker ps --format 'table {{.Names}}\t{{.Ports}}'
0.0.0.0:5432->5432/tcp means published on all interfaces, so it’s public. Test from another machine (not the server itself):
nc -vz your-server-ip 5432
nmap -p 1-10000 your-server-ip
Fix 1 (simplest, recommended): bind to localhost
Publish only on 127.0.0.1, and let a reverse proxy on the host handle public traffic:
services:
app:
ports:
- "127.0.0.1:8080:8080"
db:
# no ports at all: other containers reach it via the Compose network as "db:5432"
Databases, Redis and admin panels usually don’t need published ports at all. Containers on the same Compose network talk to each other by service name. See Docker networking.
For public web apps, use Nginx, Caddy or Traefik in front with HTTPS. See reverse proxy for multiple Docker containers and Caddy with Docker.
Fix 2: bind to a private/VPN interface
ports:
- "10.8.0.1:5432:5432" # only reachable over the WireGuard/Tailscale interface
Fix 3: filter in the DOCKER-USER chain
Docker leaves the DOCKER-USER chain for your own rules, evaluated before Docker’s. Example: allow port 8080 only from one IP:
sudo iptables -I DOCKER-USER -i eth0 -p tcp --dport 8080 ! -s 203.0.113.50 -j DROP
Note: rules see the container’s port after DNAT (the destination port inside the container). Persist them (for example with iptables-persistent), and re-check after Docker upgrades. See iptables tutorial.
Fix 4: ufw-docker
The community ufw-docker tool adds rules to UFW’s configuration, so you can manage container access with commands like ufw-docker allow <container> 80. It’s convenient if you prefer UFW syntax, but it’s an extra component to maintain.
What not to do
"iptables": falseindaemon.jsonstops Docker from managing iptables at all. It also breaks container networking (outbound NAT, published ports) unless you write every rule yourself. Not recommended.- Relying on “nobody knows the port”: scanners find open ports within minutes, especially databases and Redis.
Checklist
- No database, cache or admin UI published on
0.0.0.0. - Public apps published only via a reverse proxy on 80/443.
- Other ports bound to
127.0.0.1or a VPN interface. - External port scan matches what you expect.
- Strong passwords anyway (defence in depth).
See Docker security best practices and UFW.
Quick reference
| Situation | Fix |
|---|---|
| Web app | 127.0.0.1:8080:8080 + reverse proxy |
| Database / Redis | No ports:; use the Compose network |
| Admin access only | Bind to VPN IP |
| Need IP allowlist | DOCKER-USER chain rules |
| Check exposure | docker ps, ss -tlnp, external nmap |
Related: Docker Compose in production and Linux firewall rules.