Docker and UFW: Why Published Ports Bypass Your Firewall
This surprises almost everyone: you run ufw deny 8080 or ufw enable, yet a container published with -p 8080:80 is still reachable from the internet. UFW didn't fail. Docker simply doesn't go through the chain UFW controls.
Why it happens
UFW filters traffic in iptables' INPUT chain, which is for packets addressed to the host itself. When you publish a port, Docker adds a NAT rule that redirects incoming packets to the container, so they are forwarded, not delivered locally. They travel through the FORWARD chain and Docker's own DOCKER chain, and UFW's rules never see them.
Check what you are really exposing:
docker ps --format 'table {{.Names}}\t{{.Ports}}'
sudo ss -tlnp | grep docker-proxy
Ports shown as 0.0.0.0:8080->80/tcp or :::8080->80/tcp are open to everyone, whatever ufw status says. Test from another machine to be sure:
nc -zv SERVER_IP 8080
Fix 1 (best): bind to localhost
Publish only on the loopback interface, and let a reverse proxy (Apache, Nginx, Caddy) handle public traffic:
docker run -d -p 127.0.0.1:8080:80 nginx
In Compose:
ports:
- "127.0.0.1:8080:80"
Now only the host can reach the container; the proxy on ports 80/443 (which UFW does control) is the only way in. This is the simplest and safest rule: never publish a port you don't want the world to see. Databases, Redis and admin panels should never be published publicly at all, and services on the same Docker network can talk without published ports.
Fix 2: filter with the DOCKER-USER chain
Docker leaves the DOCKER-USER chain alone, and it's checked before Docker's own rules, so it's the supported place for your own restrictions.
Block outside access to a published container port (here the container is published on 8080):
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL -j DROP
Allow only one trusted address first (insert it above the DROP rule):
sudo iptables -I DOCKER-USER -i eth0 -s 203.0.113.10 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL -j ACCEPT
Notes:
- Replace
eth0with your public interface (ip -br addr). --ctorigdstportis needed because the destination port has already been rewritten to the container port by the time the rule runs.- These rules do not survive a reboot by themselves. Persist them with
iptables-persistent, or put them in/etc/ufw/after.rules. - Repeat with
ip6tablesif your containers are reachable over IPv6.
Fix 3: a helper tool
ufw-docker installs the needed rules once and then lets you manage container ports with commands like ufw route allow. It's convenient but you should read what it changes to your firewall files first.
What not to do
Setting "iptables": false in /etc/docker/daemon.json stops Docker from touching the firewall, but it also breaks container NAT and networking, so you would have to maintain all the rules yourself. It is not a beginner fix.
A second layer
Your hosting provider's cloud firewall or security group filters traffic before it reaches the server at all. Allow only the ports you intend (22, 80, 443) there as well. If Docker bypasses UFW by mistake, the provider firewall still blocks the port.
Checklist
- List published ports with
docker ps. - Anything that shouldn't be public gets
127.0.0.1:in front of the port mapping. - Verify from an outside machine with
nc -zvor an online port scanner. - Add a provider-level firewall as a backstop.
- Re-check after adding any new container.