
You set UFW to deny all incoming traffic except SSH and started a database container with -p 5432:5432. A port scan from home shows 5432 open. You add sudo ufw deny 5432/tcp, ufw status lists the rule, and the port stays open. UFW is working. Docker's traffic never reaches its rules.
Quick answer: Docker publishes container ports with its own NAT rules, and its documentation says those packets "are diverted before it reaches the INPUT and OUTPUT chains that ufw uses." Ordinary ufw allow and ufw deny rules don't touch them. Fix it at the port: publish internal services on 127.0.0.1 only (-p 127.0.0.1:5432:5432), or don't publish them and let containers talk over a Docker network. Public ports that need a filter go through the DOCKER-USER chain or the community tool ufw-docker. Don't set "iptables": false. Then test from another machine.
Why UFW Never Sees Docker's Ports
UFW filters incoming traffic in the INPUT chain, which the iptables man page describes as "for packets destined to local sockets". A published port isn't one. Docker's port publishing docs explain that -p 8080:80 maps a host port to the container with NAT, so the packet's destination is rewritten to the container's address and it is forwarded. Docker's iptables page says the FORWARD chain jumps "unconditionally" to Docker's own chains, and packets they accept "will not be seen by user-defined rules appended to the FORWARD chain."
sudo ufw status won't warn you, because it "only shows rules added with ufw" (ufw-framework man page). Docker's Ubuntu and Debian install pages warn that with ufw or firewalld, published ports "bypass your firewall rules."
What the Risk Looks Like With a Database
Server commands here need root or sudo and work the same on Ubuntu 24.04 and Debian 12 (on Debian, run sudo apt install ufw if it's missing). On a Space-Node VPS you get KVM with full root access, so nothing here is blocked. New to UFW? Start with our UFW and Fail2Ban basics. If SSH uses another port, allow that one instead of 22.
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw enable
sudo docker run -d --name pg -e POSTGRES_PASSWORD=change-me -p 5432:5432 postgres:18
sudo ufw deny 5432/tcp
From your laptop (use your server's IP instead of 203.0.113.5):
nc -zv -w 5 203.0.113.5 5432
Connection to 203.0.113.5 5432 port [tcp/postgresql] succeeded!
The deny rule changed nothing, as the ufw-docker README also shows with ufw deny 8080. Anyone scanning your IP now reaches the Postgres login, where the image docs make postgres the default superuser. If a tutorial had you set POSTGRES_HOST_AUTH_METHOD=trust, those docs warn it "allows anyone to connect without a password." The redis image is worse: protected mode is off, so a port published with -p "will be open without a password to anyone." Remove the test container afterwards with sudo docker rm -f pg.
Step 1: List Every Port You Published
sudo docker ps --format 'table {{.Names}}\t{{.Ports}}'
In the PORTS column, 127.0.0.1:5000->5000/tcp is reachable from the server only. 0.0.0.0:5432->5432/tcp is open on every IPv4 address, and [::] means every IPv6 address. A mapping without an address gets the open kind: the bridge driver docs say the port is then "available on all host addresses, IPv4 and IPv6."
One Compose trap: - "5432" under ports: isn't internal. The Compose reference says the runtime "automatically allocates any unassigned port of the host", so it's published on a random port, on every address. Then sort the list: ports that must be public (a reverse proxy's 80 and 443, game ports) and the rest.
Fix 1: Publish Internal Services on 127.0.0.1
Docker's docs: "If you include the localhost IP address (127.0.0.1, or ::1) with the publish flag, only the Docker host can access the published container port." With docker run that's -p 127.0.0.1:5432:5432. In Compose:
services:
db:
image: postgres:18
ports:
- "127.0.0.1:5432:5432"
Keep the quotes: the Compose reference says HOST:CONTAINER "should always be specified as a (quoted) string". Then run sudo docker compose up -d. Compose replaces the container, so keep database files in a named volume or bind mount, as the compose down docs advise "for data that needs to persist between updates."
Check your version with sudo docker version --format '{{.Server.Version}}'. Docker warns that in releases older than 28.0.0, "hosts within the same L2 segment (for example, hosts connected to the same network switch) can reach ports published to localhost." Update Docker first if yours is older.
Safety net. To make 127.0.0.1 the default for a Compose project, set it on the project's default network, as the networks reference shows:
networks:
default:
driver_opts:
com.docker.network.bridge.host_binding_ipv4: "127.0.0.1"
The option applies when the network is created, so run sudo docker compose down and sudo docker compose up -d once. Ports that must stay public then need their address written out, such as "0.0.0.0:443:443" plus "[::]:443:443" for IPv6. Server wide, the /etc/docker/daemon.json keys "ip" (default bridge) and default-network-opts (new user-defined networks) do the same, with the JSON on Docker's port publishing page, but changing them means sudo systemctl restart docker, and by default a stopping daemon shuts down running containers.
Fix 2: Don't Publish Internal Services at All
A database that only other containers use needs no published port. Docker's docs say "all ports of containers on bridge networks are accessible from the Docker host and other containers connected to the same network", and Compose registers each service name on the project network, so your app connects to db:5432. Lines under expose: publish nothing and aren't needed.
To isolate the database further, put it on a network marked internal, which Compose calls "an externally isolated network":
services:
app:
image: your-app:latest
ports:
- "127.0.0.1:3000:3000"
networks:
- front
- back
db:
image: postgres:18
environment:
POSTGRES_PASSWORD: change-me
volumes:
- pgdata:/var/lib/postgresql
networks:
- back
networks:
front:
back:
internal: true
volumes:
pgdata:
The app joins both networks, so it reaches the internet and the database; a reverse proxy on the host sends your domain to 127.0.0.1:3000. The database has no ports entry and sits only on back, so nothing outside the server can connect to it. From version 18 the postgres image uses /var/lib/postgresql as its volume path; for 17 and older its docs say /var/lib/postgresql/data. Our Docker Compose production guide uses the same split. Every extra network draws on Docker's address pool; if that runs out, see our address pool fix.
Fix 3: Filter a Public Port in the DOCKER-USER Chain
Some ports must stay reachable, but only from certain addresses, such as an admin panel. Docker provides the DOCKER-USER chain for this, "a placeholder for user-defined rules that will be processed before rules in the DOCKER-FORWARD and DOCKER chains."
By then the destination has been rewritten, so plain flags "can only match internal IP addresses and ports of containers." Match the port the client used with conntrack. First find your external interface, the word after dev, for example eth0:
ip route show default
This drops new connections to host port 5432 from everyone except 203.0.113.10 (leave out ! -s 203.0.113.10 to block everyone):
sudo iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.10 -p tcp -m conntrack --ctstate NEW --ctorigdstport 5432 -j DROP
sudo iptables -L DOCKER-USER -n -v --line-numbers
Docker notes that conntrack "may result in degraded performance." To remove a rule, run sudo iptables -D DOCKER-USER followed by its line number.
Two limits. The Debian wiki says iptables rules "will be deleted ("flushed") with the next reboot." And DOCKER-USER belongs to Docker's default iptables backend: the experimental nftables backend added in Docker 29.0.0 ("firewall-backend": "nftables") has no such chain, and its nftables page shows how to use your own table instead. The install pages also say "Firewall rules created with nft are not supported on a system with Docker installed", so with the default backend, write these rules with iptables or ip6tables.
Fix 4: Let UFW Manage Container Ports With ufw-docker
ufw-docker is a community project (GPL-3.0), not part of Docker or Ubuntu. ufw-docker install only edits UFW's rules files: it backs up /etc/ufw/after.rules and appends a block that routes DOCKER-USER traffic through UFW's forward rules and drops new connections from public addresses to containers. It does the same for IPv6 in /etc/ufw/after6.rules and leaves Docker's settings at their defaults.
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 check
sudo ufw-docker install
sudo systemctl restart ufw
Read the script before you run it as root. Afterwards every published port is closed to the internet until you open it with a route rule on the container port: for -p 8080:80, the README says to allow 80. The first line opens container port 80 on every container, the second only on the container named web:
sudo ufw route allow proto tcp from any to any port 80
sudo ufw-docker allow web 80
ufw-docker allow writes the rule for the container's current IP, so run sudo ufw-docker reload after containers are recreated. sudo ufw-docker install-service adds a systemd unit that runs that reload each time Docker starts, for example after a reboot. By default the block trusts 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16 (fd00::/8 for IPv6); --docker-subnets narrows that. If the rules don't take effect after a UFW restart, the README says to reboot. Like Fix 3, it needs Docker's default iptables backend.
Why "iptables": false Is the Wrong Fix
Many older guides, as the ufw-docker README notes, tell you to set "iptables": false in /etc/docker/daemon.json. Docker's packet filtering page says this "is not appropriate for most users, it is likely to break container networking": without replacement rules, containers "will not be able to access internet hosts by masquerading, but all of their ports will be accessible to hosts on the local network." The dockerd reference adds that you then write the rules that expose container ports yourself. And Docker can't be stopped entirely: "It is not possible to completely prevent Docker from creating firewall rules".
Check IPv6 as Well
A mapping without an address is also published on IPv6, even when the container's network has none: Docker's port publishing page says the host's IPv6 addresses then map to the container's IPv4 address while the userland proxy is on, which is the default. If your VPS has a public IPv6 address, test it separately. iptables handles IPv4 and ip6tables handles IPv6, so a DOCKER-USER rule added with iptables doesn't cover it. The 127.0.0.1 binding avoids the question, because -p 127.0.0.1:5432:5432 is IPv4 only; Docker's own example adds -p '[::1]:8080:80' when you want the IPv6 loopback too.
Test From Outside the Server
Test from your laptop or a second server, not from the VPS itself: Nmap's docs note that a scan from the target's own network can show a port as open that a scan from across the internet shows as filtered.
nmap -Pn -p 5432,6379 203.0.113.5
nmap -6 -Pn -p 5432,6379 2001:db8::5
-Pn skips host discovery and -p picks the ports. open means something accepted the connection, filtered means a packet filter dropped the probe (what you want for a private port), and closed means the server answered but nothing listens. Netcat checks one port (nc -6 for IPv6):
nc -zv -w 5 203.0.113.5 5432
Connection to 203.0.113.5 5432 port [tcp/postgresql] succeeded!
nc: connect to 203.0.113.5 port 5432 (tcp) failed: Connection refused
nc: connect to 203.0.113.5 port 5432 (tcp) timed out: Operation now in progress
The first line means the port is open to the internet, the second that nothing listens or a rule rejected the connection, and the third is what a DROP rule looks like. On Windows, PowerShell's Test-NetConnection -ComputerName 203.0.113.5 -Port 5432 reports TcpTestSucceeded. For UDP ports use sudo nmap -sU (UDP scans need root), which often says open|filtered because an open UDP port may not answer.
If It Still Fails
Still open after editing the compose file. The old container still has the old mapping. Run sudo docker compose up -d and check docker ps again.
Nothing public in docker ps, but the scan says open. Something else is listening. sudo ss -ltnp lists listening TCP sockets and their processes; a container with network_mode: host appears there under its own program name.
The DOCKER-USER rule is gone after a reboot. Rules added with the iptables command don't survive one. Add it again, or switch to ufw-docker, whose rules UFW loads at boot.
DOCKER-USER rules do nothing. Run sudo nft list tables. A docker-bridges table means Docker uses the nftables backend, where rules in DOCKER-USER "will be ignored" unless an old jump remains.
FAQ
Will binding the database to 127.0.0.1 break my app's connection to it?
No, if both containers share a Docker network. The app uses the service name and container port, such as db:5432, not the published port.
How do I reach a database that only listens on 127.0.0.1 from my own computer?
With an SSH tunnel: ssh -N -L 5432:127.0.0.1:5432 user@203.0.113.5, then point your database tool at localhost:5432. Per the ssh man page, -L forwards a local port to a host and port on the remote side, and -N is "useful for just forwarding ports." Our uptime monitoring guide uses the same tunnel for a dashboard.
Does firewalld have the same problem?
Yes, Docker's install pages warn about both. Docker puts its bridges in a firewalld zone called docker with target ACCEPT, and lets any zone forward into it.
Does network_mode: host avoid the bypass?
Yes, at a cost. With host networking, Docker ignores -p, creates no firewall rules for the container and uses no NAT, so its ports behave like any program on the host. But the Compose docs warn that such a container "can access all host ports and observe all network traffic on the host."
I limited ports 80 and 443 to Cloudflare's IP ranges with UFW. Does that cover a web server in Docker?
Not if Docker publishes its ports: plain ufw allow rules, like the allow-list in our Cloudflare 521 guide, never see that traffic, so anyone with your IP can still connect directly. Bind the container to 127.0.0.1 behind a reverse proxy on the host, which UFW does filter, or add each range from Cloudflare's list as a ufw-docker ufw route allow rule on the container port.
Sources
All opened on 29 October 2026:
- Docker: Packet filtering and firewalls, Port publishing, Docker with iptables, Docker with nftables, Bridge driver, Host driver, IPv6, Live restore, Install on Ubuntu, Install on Debian, dockerd, docker container ls, docker container rm, docker version
- Compose: Services, Networks, Networking in Compose, docker compose down
- chaifeng/ufw-docker, README and script
- Man pages: ufw(8), ufw-framework(8), iptables(8), iptables-extensions(8), nft(8), nc(1), ssh(1)
- Debian wiki: iptables
- Image docs: postgres, redis
- Nmap: Options summary, Port scanning basics, Port scanning techniques
- Microsoft: Test-NetConnection
- Cloudflare IP ranges