Docker publishes ports straight past UFW: hardening a fresh VPS without the firewall rule that does nothing
ufw deny 8080 does not stop a Docker container published on 8080. Docker's own docs explain why. A 15-minute fresh-VPS walkthrough: key-only SSH, ufw, then the two fixes that actually close a container port, and a scan from outside to prove it.
if.if.codesOct 11, 2026 · 6 min read#docker#security#vps#firewallAI-assisted
A one-command VPS hardening script went round X this week, and the replies were the usual mix: people who had never thought about it, and people who had already been bitten. The bite is almost always the same one. You enable ufw, you allow only SSH and HTTPS, you run docker run -p 8080:80 something, and port 8080 is open to the whole internet anyway.
That is not a ufw bug and not a misconfiguration on your side. Docker's documentation says it plainly: Docker and ufw use firewall rules in ways that make them incompatible, because Docker routes container traffic in the nat table, so packets are diverted before they reach the INPUT and OUTPUT chains that ufw uses. Your ufw deny rule is real; the packets just never pass through it.
This guide takes a fresh Ubuntu/Debian VPS to a state where the only open ports are the ones you meant, in about 15 minutes. Everything runs as a sudo user, not root.
1. SSH: keys only
From your laptop, copy your key first (and test that you can log in with it before you change anything):
Securing server access with SSH keys is the first line of defense.
On the server, drop a config snippet instead of editing the main file, so package upgrades do not fight you:
sudotee /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
EOF
sudo sshd -t && sudo systemctl reload ssh
sshd -t checks the config before the reload. Keep your current session open and confirm a second login works before you close it. On some distributions the service is called sshd rather than ssh.
Now, from a different machine (your laptop, not the server):
curl -sI http://203.0.113.10:8080 | head -n 1
HTTP/1.1 200 OK
The ufw rule is there and the page still loads. Docker's port-publishing docs explain the default: when a port is mapped without a host address, the daemon publishes it on all host addresses, 0.0.0.0 and [::]. The same page calls publishing container ports "insecure by default".
Need the backend underneath done properly?I build and harden Go and Python backends. The estimate is free.
5. Fix A: bind published ports to localhost
For most small setups this is the right fix. Your app containers do not need to be reachable from the internet; your reverse proxy (Caddy, Traefik, nginx) does. Publish the app on the loopback address only and let the proxy on 80/443 forward to it:
Binding ports to localhost ensures services are only reachable via a controlled proxy.
To make loopback the default for the default bridge network, so a forgotten address does not open a port, set the ip key in /etc/docker/daemon.json. Do not confuse it with bip: bip sets the address of the docker0 bridge itself, while ip is the default host address that published ports bind to (the daemon's --ip flag, default 0.0.0.0). Docker's port-publishing page documents exactly this use:
{"ip":"127.0.0.1"}
sudo systemctl restart docker
Note the scope: Docker documents this key for the default bridge network. Containers on user-defined networks (which Compose creates) still need the explicit 127.0.0.1: prefix, so keep writing it.
6. Fix B: rules in the DOCKER-USER chain
Sometimes a container port really must be public, but only to some addresses (an office IP, a monitoring service). Do not edit Docker's own chains: the docs warn against modifying the rules Docker creates. Docker gives you a chain for this instead, DOCKER-USER, which is processed before its own DOCKER-FORWARD and DOCKER chains.
Find your external interface name first:
ip -o route get 1.1.1.1 | awk '{print $5}'
Then, with eth0 as an example, drop everything arriving on that interface that is not from your allowed range. This is the docs' own example pattern:
Two things to know. First, that rule drops all external traffic to all published containers except from the range, so if you also serve a public site from a container, allow it explicitly. Docker's docs show matching on the original destination port with conntrack, because by the time packets reach this chain the destination has already been rewritten to the container's address:
The docs note the conntrack extension may degrade performance. Also let reply traffic back in, or containers lose their own outbound connections (package downloads, API calls), since the replies arrive on the external interface too:
sudo iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPT
Second, order matters. iptables -I inserts at the top of the chain, so the command you run last is evaluated first. Run the commands in the order shown here (DROP, then the port ACCEPT, then the ESTABLISHED ACCEPT) and the chain reads ACCEPT, ACCEPT, DROP from the top. Check it before you log out:
sudo iptables -L DOCKER-USER -n --line-numbers
The DROP rule must be below both ACCEPT rules. These rules are not persistent across reboots on their own; save them with whatever your distro uses (for example iptables-persistent) and re-check after a Docker upgrade.
If you only remember one of the two fixes, make it Fix A. It needs no firewall knowledge and it fails closed.
7. Prove it from outside
The only test that counts is one run from another network. From your laptop:
nmap -Pn -p 22,80,443,8080 203.0.113.10
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
443/tcp open https
8080/tcp filtered http-proxy
And on the server, list what is actually listening on all addresses:
sudo ss -tlnp | grep -v '127.0.0.1'
Anything bound to 0.0.0.0 or [::] that you did not expect is a port to explain. Run the scan again after every new docker compose up that adds a ports: entry; this is where leaks come back.