You set up UFW on your VPS, locked it down to just SSH and a couple of web ports, and felt good about it. Then you installed Docker, ran a container with -p 5432:5432 or -p 3306:3306, and somehow that port is reachable from the open internet — even though ufw status swears it's denied. This isn't a bug you introduced. It's how Docker and UFW have always interacted, and it catches almost everyone who runs both on the same box.
Symptom
You publish a container port with something like:
docker run -d -p 3306:3306 mysql:8
Then you check your firewall:
sudo ufw status
Status: active
22/tcp ALLOW Anywhere
80/tcp ALLOW Anywhere
443/tcp ALLOW Anywhere
No rule for 3306. You'd expect it to be blocked. But run a port scan from another machine, or just try telnet your-vps-ip 3306, and it connects anyway. On a database port, that's a live exposure — the kind that shows up in shodan scans and gets hit by credential-stuffing bots within hours.
Cause
UFW manages the standard iptables filter table — the INPUT chain, mostly. Docker doesn't use that chain at all. When you publish a port with -p, the Docker daemon inserts its own rules directly into the nat table (DOCKER chain) and the filter table's FORWARD chain, and it inserts them before UFW's rules get a chance to apply. Docker also flips net.ipv4.ip_forward to 1 so forwarded traffic actually reaches the container's internal IP.
Traffic hitting a published container port is being forwarded (via NAT) to the container's bridge network, not delivered to the host's own INPUT chain. UFW's rules only ever look at INPUT/OUTPUT, so a packet destined for a container simply never passes through the chain UFW controls. Every -p you publish is, by default, punched straight through your firewall regardless of what ufw status says.
This isn't unique to old Docker versions or a misconfiguration on your part — it's documented, longstanding behavior, and it's the single most common reason a "properly firewalled" VPS turns out to have an open database port.
Fix
You have three realistic options, in order of how much they change your setup.
Option 1 — Stop publishing ports you don't need to publish
If a container only needs to talk to another container (say, your app talking to a Postgres container), don't use -p at all. Put both containers on the same user-defined Docker network and reach the database by its container name over the internal network:
docker network create app-net
docker run -d --name db --network app-net -e POSTGRES_PASSWORD=secret postgres:16
docker run -d --name app --network app-net myapp:latest
Inside app, connect to db:5432. No host port is published, so there's nothing for UFW to fail to protect. This is the cleanest fix and the one to reach for first — half of the "Docker bypassed my firewall" reports are really "I didn't need to expose this port at all."
Option 2 — Bind published ports to localhost only
If you genuinely need the port reachable from the host (for a local admin tool, a reverse proxy on the same machine, etc.) but not from the outside world, bind it to 127.0.0.1 instead of all interfaces:
docker run -d -p 127.0.0.1:3306:3306 mysql:8
This is the single biggest quick win: change every -p 3306:3306 to -p 127.0.0.1:3306:3306 in your docker run commands and docker-compose.yml files. In Compose syntax:
services:
db:
image: mysql:8
ports:
- "127.0.0.1:3306:3306"
Docker still writes the NAT rule, but the rule only forwards traffic arriving on the loopback interface, so nothing external can reach it. Your Nginx reverse proxy or app on the same VPS can still connect fine.
Option 3 — Tell Docker to respect UFW (ufw-docker)
If you genuinely need a container port reachable from specific external IPs, use the community ufw-docker tool. It rewrites Docker's chain-insertion behavior so UFW rules are actually consulted for Docker-forwarded traffic:
sudo wget -O /usr/local/bin/ufw-docker \
https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
sudo chmod +x /usr/local/bin/ufw-docker
sudo ufw-docker install
sudo systemctl restart ufw
After installing it, allow access per-container instead of relying on plain UFW rules:
sudo ufw-docker allow db 3306
sudo ufw route allow proto tcp from 203.0.113.10 to any port 3306
This gets you real IP-based filtering on published container ports, but it adds a moving part you have to remember exists every time you add a new container. For most single-app VPS setups, Option 1 or 2 is simpler and has less to maintain.
Belt-and-suspenders: check what's actually forwarding
Whichever fix you apply, verify it at the packet level instead of trusting ufw status:
sudo iptables -t nat -L DOCKER -n --line-numbers
sudo iptables -L FORWARD -n --line-numbers | grep -i docker
If you see a rule forwarding your port to a container IP with no source restriction, it's open, no matter what UFW's own table says. Follow up with an external check — from a different network, run nc -zv your-vps-ip 3306 or use an online port checker. A firewall rule you haven't verified from outside the box is a guess, not a fact.
Prevention
- Default to
127.0.0.1:-bound ports for anything that isn't meant to be public. Make it a habit in everydocker-compose.yml, not just the ones you remember to lock down. - Put databases, caches, and admin panels on an internal Docker network with no published port at all whenever the only consumer is another container on the same host.
- If you run a reverse proxy (Nginx, Traefik, Caddy) in front of your containers, that's usually the only thing that needs a public port (80/443). Everything behind it can stay internal.
- After any
docker runordocker compose upthat adds a new published port, re-run your external port check. Don't assume your existing UFW rules still describe reality. - If you manage several VPS instances running Docker, add a scheduled port scan (even a simple cron job with
nmapagainst your own public IP) so a forgotten-p 0.0.0.0:5432:5432gets caught in hours, not months.
None of this means UFW is broken or Docker is misbehaving — they're just operating on different layers of the network stack, and nobody tells you that until a port turns up open that you were sure was closed. Once you know the rule (Docker's NAT bypasses UFW's INPUT chain), the fix is usually a one-line change per port.
Frequently Asked Questions
Does this affect firewalld or CSF the same way?
Yes, if they're built on the same underlying iptables/nftables filter table and only manage the INPUT chain. CSF (ConfigServer Firewall) has its own Docker-awareness options in newer versions — check DOCKERFIX in csf.conf if you're running CSF on a cPanel VPS alongside Docker. Firewalld has a similar gap unless you explicitly configure its Docker integration.
I'm on Docker Compose — do I need to change anything besides the ports line?
No. Changing "3306:3306" to "127.0.0.1:3306:3306" in the ports: section of your docker-compose.yml is enough. Run docker compose up -d again to apply it, then re-check with an external port scan.
Will binding to 127.0.0.1 break my reverse proxy if it's also in a container?
It can, since 127.0.0.1 on the host isn't the same as 127.0.0.1 inside another container's network namespace. If your proxy is itself a container, put both containers on the same user-defined Docker network (Option 1) instead of relying on the loopback binding.
Can I just disable Docker's iptables management entirely?
You can, by setting "iptables": false in /etc/docker/daemon.json, but then published ports stop working unless you manually write every NAT and forwarding rule yourself. It's more control but a lot more maintenance — only worth it if you're already comfortable hand-managing iptables.
How do I know if I already have containers exposed like this right now?
Run docker ps and look at the "PORTS" column for anything showing 0.0.0.0:PORT->PORT/tcp rather than 127.0.0.1:PORT->PORT/tcp. Every 0.0.0.0 entry is reachable from the public internet right now, regardless of your UFW rules, until you apply one of the fixes above.
