Docker bypassed my firewall: the database port was open to the internet
My host firewall blocked port 5432. It was reachable from the internet anyway, because Docker writes its own iptables rules that your INPUT and ufw rules never see. Why it happens, and how to actually close it.
I had a firewall. I had explicitly blocked the Postgres port on the host. And Postgres was still reachable from the internet.
I found out the way you never want to: the database was taking connection attempts from IP addresses that had no business talking to it. My firewall rules said port 5432 was closed. A scan from a machine outside the server said it was wide open. Both were telling the truth, and the gap between them is one of the most common production traps in the Docker world.
This is part 2 of a series on hardening a solo-built SaaS in production. The setup and the full list of weak spots are in the pillar article. This one is about the day I learned that Docker does not care about the firewall I thought I had.
Why the firewall did nothing
Here is the mental model most people have, and why it is wrong. You picture traffic hitting the host, going through your INPUT chain, and getting dropped by your rule. Tools like ufw reinforce this: you type ufw deny 5432, it says the rule is active, and you move on.
But a published Docker port does not travel through INPUT. When you publish a port, Docker rewrites the firewall for you. It adds a DNAT rule in the nat table that redirects incoming traffic to the container, and it accepts that traffic through the FORWARD chain, because from the kernel’s point of view the packet is being routed to another network (the container’s), not delivered to the host itself.
Your ufw deny and your handwritten INPUT ... DROP rules govern the INPUT chain. Docker’s published ports are handled in FORWARD. The two never meet. So the firewall was doing exactly what I told it: dropping traffic to the host on 5432. Docker was quietly forwarding traffic to the container on 5432, through a completely different path, and it wins because it runs first.
The convenience that makes Docker pleasant (publish a port and it just works) is the exact mechanism that punched a hole through my firewall without a word.
The one binding that was already right
There is a good habit in the compose file, and it is worth calling out because it is the cheapest fix of all. The database publishes its port like this:
db:
ports:
- '127.0.0.1:5432:5432' # loopback only, not the whole internet
That 127.0.0.1: prefix matters enormously. Without it, - '5432:5432' tells Docker to publish on 0.0.0.0, every interface, including the public one. With it, Docker’s DNAT rule only matches traffic that arrived on loopback, so nothing from outside the machine can reach it. This is the binding that closed the hole.
The honest part: the same file still publishes other services on all interfaces.
api:
ports: ['8000:8000'] # 0.0.0.0, exposed
storage:
ports: ['9000:9000', '9001:9001'] # object storage AND its admin console, exposed
So the database is fixed, but the API on 8000 and the MinIO admin console on 9001 are still published to the entire internet. Which leads to the better question.
The real fix: internal services need no published ports at all
Publishing a port is for traffic that comes from outside the machine. Look at what actually needs that: nothing except nginx. The reverse proxy already reaches every other service by name over the Docker network:
# nginx talks to the containers directly, over the internal network
proxy_pass http://api:8000;
proxy_pass http://storage:9000;
nginx does not use the host-published 8000 or 9000. It uses Docker’s internal DNS and the shared network. Which means the ports: entries on api, storage, and the dashboard are not just risky, they are pointless. Nothing outside the compose network uses them.
So the fix for most services is to delete the port publishing entirely. The only container that should publish to the host is nginx, on 80 and 443. Everything else talks over the internal network, invisible from outside:
nginx:
ports: ['80:80', '443:443'] # the only public surface
api:
# no ports: block at all, nginx reaches it as http://api:8000 internally
db:
ports: ['127.0.0.1:5432:5432'] # loopback only, for local psql/backups
If you genuinely need a port reachable from outside but restricted (say, Postgres from one office IP), do not reach for ufw. Put the rule in the DOCKER-USER chain, the one iptables chain Docker guarantees it evaluates before its own rules and never flushes:
# only this source may reach forwarded container traffic on 5432
iptables -I DOCKER-USER -p tcp --dport 5432 ! -s 203.0.113.10 -j DROP
Audit from outside, never trust iptables -L
The lesson underneath all of this: your firewall config is a claim, not a fact. iptables -L and ufw status show you what you intended, through the chain you were looking at. They will happily show 5432 as denied on INPUT while it is wide open through FORWARD.
The only source of truth is a scan from a machine that is not the server:
# from your laptop, not from the box
nmap -Pn -p 5432,8000,9000,9001 your.server.ip
If anything other than 80 and 443 comes back as open, you have a service exposed that you probably did not mean to expose. That single command would have told me about the database months before an attacker did.
The takeaway
Docker manages the firewall for you, and it does not consult the one you wrote. Publishing a port silently adds rules in FORWARD and nat that your INPUT and ufw rules never touch, so “the port is blocked” and “the port is open” can both be true at once. Bind anything you must publish to 127.0.0.1, publish nothing that nginx can proxy internally, use DOCKER-USER for real restrictions, and verify with an external scan rather than a local list.
Closing the ports is only half of perimeter security. The other half is what to do about the traffic that is still allowed to hit nginx: the constant scans and brute-force, and the day my TLS certificate silently failed to renew. That is a later article in this series. The series overview tracks all of it.