You enable UFW, allow only SSH and HTTPS, then start a container with -p 8080:80. From home, port 8080 still answers. This is not a UFW bug. Docker writes its own iptables rules, and traffic to published ports never reaches the chains UFW filters. This guide explains why and gives two reliable fixes.
Before you start
- A Linux VPS running Ubuntu 22.04, Ubuntu 24.04 or Debian 12 with UFW active and Docker Engine installed. See Install Docker and Docker Compose.
- A
sudouser connected over SSH, with SSH allowed in UFW. - A second machine, such as your home computer, to test ports from outside.
Warning: This guide edits /etc/ufw/after.rules. A syntax error there stops UFW from loading. Copy the file first with sudo cp /etc/ufw/after.rules /etc/ufw/after.rules.bak, and keep the browser console in your VPS provider's control panel open as a way back in.
Why Docker skips UFW
UFW filters traffic in the iptables INPUT chain, which handles packets addressed to the host itself. When you publish a port, Docker adds a rule in the nat table that rewrites (DNATs) incoming traffic for that port to the container's private address. After that rewrite, the packet is no longer for the host. It is forwarded to the container through the FORWARD chain, and Docker's own DOCKER chains in FORWARD accept it.
The result: UFW's INPUT rules never see the packet, so ufw deny 8080 does nothing for a published container port.
You can see Docker's rules with:
sudo iptables -t nat -S DOCKER
sudo iptables -S FORWARDDocker provides one chain for your own rules: DOCKER-USER. It is checked before Docker's rules in FORWARD, and Docker never flushes it.
Fix 1: bind ports to 127.0.0.1
Most containers do not need to be public at all. They sit behind a reverse proxy such as Nginx or Caddy on the same server. In that case, publish the port on the loopback address only:
docker run -d --name web -p 127.0.0.1:8080:80 nginxIn Docker Compose:
services:
web:
image: nginx
ports:
- "127.0.0.1:8080:80"The port is now reachable from the server itself (and from your reverse proxy) but not from the internet. The proxy listens on 80 and 443, which UFW controls as normal.
For databases and caches, the cleanest option is no ports: entry at all. Containers on the same Docker network can reach each other by service name without publishing anything.
This is the simplest fix and should be your default.
Fix 2: filter in the DOCKER-USER chain
If a container must be public, but only to some addresses, add rules to DOCKER-USER. Placing them in UFW's after.rules file means they load every time UFW starts.
First find your public interface name:
ip -br addrIt is often eth0 or ens3. The example below uses eth0. Open the file:
sudo nano /etc/ufw/after.rulesAdd this block at the very end, after the existing COMMIT line:
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 443 --ctdir ORIGINAL -j RETURN
-A DOCKER-USER -i eth0 -p tcp -s 203.0.113.10 -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL -j RETURN
-A DOCKER-USER -i eth0 -m conntrack --ctstate NEW -j DROP
-A DOCKER-USER -j RETURN
COMMITWhat each line does:
- Lets replies to existing connections through, so containers can still reach the internet.
- Allows anyone to reach a container published on host port 443.
- Allows only
203.0.113.10to reach a container published on host port 8080. - Drops every other new connection coming in on
eth0to any container. - Returns everything else (container-to-container and outgoing traffic) to Docker's normal rules.
The --ctorigdstport match is important. By the time a packet reaches DOCKER-USER, its destination port has already been rewritten to the container's port. Conntrack remembers the original port the client used, which is the published host port you care about.
Apply the change:
sudo ufw reload
sudo iptables -S DOCKER-USERThe second command should list your rules. If Docker started before UFW at boot and the chain looks empty, restart Docker with sudo systemctl restart docker and check again.
Note: These rules cover IPv4. If you enabled IPv6 in Docker, add matching rules to /etc/ufw/after6.rules using IPv6 addresses. Docker networking is also changing in newer releases, so check Docker's current packet-filtering documentation if your version uses nftables instead of iptables.
Avoid "iptables": false
Some posts suggest setting "iptables": false in /etc/docker/daemon.json. That stops Docker from writing any firewall rules, which also breaks container internet access and port publishing unless you rebuild NAT by hand. Use binding or DOCKER-USER instead.
Check it works
Always test from outside the server. A test from the VPS itself goes through the loopback interface and proves nothing.
From your home computer:
nc -vz -w 3 203.0.113.10 8080
nc -vz -w 3 203.0.113.10 443Ports you blocked or bound to 127.0.0.1 should time out. Ports you allowed should succeed. For a wider check, run nmap -Pn -p 1-10000 203.0.113.10 from outside and compare the open ports against your plan.
On the server, confirm what Docker publishes and where:
docker ps --format '{{.Names}} {{.Ports}}'Entries starting 0.0.0.0: or [::]: are public unless DOCKER-USER filters them. Entries starting 127.0.0.1: are local only.
Troubleshooting
- UFW fails to reload after editing after.rules: check that your block starts with
*filterand ends withCOMMIT, and that the interface name is correct. Restore the backup if needed. - Containers lost internet access: the
RELATED,ESTABLISHEDline is missing, or the drop rule matches outgoing traffic. Make sure it uses-i(incoming interface), not-o. - An allowed port is still blocked: you matched the container port instead of the published host port. Use
--ctorigdstportwith the host port. - Rules vanish after a Docker restart: rules in
DOCKER-USERshould survive. If they do not, runsudo ufw reloadafter Docker starts.
Next steps
- Harden the containers themselves: Docker security hardening.
- Put a reverse proxy in front of local-only ports: Set up Nginx as a reverse proxy.
- See every listening port on the host: Check which ports are open and listening on Linux.
