Skip to main content

Published

Docker published ports bypass UFW

During a DDoS incident I found that a container’s published port answered from the internet even though UFW on the host was enabled. Docker had written its own iptables rules for that port, and none of my UFW rules applied to the traffic.

  • Docker
  • UFW
  • iptables
  • DOCKER-USER
  • firewall
  • Linux

Why UFW doesn’t filter a published port

The rules you add with ufw allow and ufw deny apply to traffic addressed to the host itself. A published port works differently. With -p 8080:80, Docker adds a DNAT rule that rewrites the destination to the container’s address, so the packet is forwarded to the container instead of being delivered to the host.

Forwarded traffic goes through Docker’s own chains, which accept it before UFW’s rules are reached. ufw status still shows the default deny for incoming traffic, and the container answers anyway.

In the incident the application ran in a container with a published port and no reverse proxy in front of it. Anyone who knew the origin IP could reach the application directly, past Cloudflare and past the host firewall.

How to check a server from outside

A test run on the server itself proves nothing here, because local connections take a different path through the firewall. The check has to come from another network.

On the host, docker ps shows which ports are bound to 0.0.0.0 or [::], meaning every interface. Test each of those that UFW is supposed to block from outside.

  • From another network: nmap -Pn -p 8080 <origin IP>, or curl -v http://<origin IP>:8080/.
  • On the host: docker ps --format "{{.Names}} {{.Ports}}" to list the published ports.
  • On the host: iptables -L DOCKER-USER -n -v to see whether anything filters them.

Three ways to close the port

Which one fits depends on whether anything outside the host needs that port at all.

Setting "iptables": false in /etc/docker/daemon.json stops Docker from writing rules, but then containers lose the NAT they need for outbound traffic unless those rules are written by hand.

Filtering in DOCKER-USER doesn’t have to be set up by hand. I used ufw-docker, an open-source script that adds the rules to /etc/ufw/after.rules and makes DOCKER-USER check the rules you add with ufw route. Docker keeps managing iptables itself, new connections from public addresses to containers are dropped, and you open a port with ufw route allow or ufw-docker allow <container> <port>. The rule matches the container port, not the host port, so with -p 8080:80 you allow 80. By default the script also lets through all traffic from the private ranges 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16, which matters when the server sits on a provider’s private network; the --docker-subnets option narrows that.

  • Bind the port to localhost, for example "127.0.0.1:8080:80" in Docker Compose, and put a reverse proxy on the host in front of it. Only the proxy listens on a public interface. It is the simplest fix when the proxy is the only thing that should be public; the cost is one more service to configure.
  • Filter in the DOCKER-USER chain. Docker sends forwarded traffic through it before its own rules, so a DROP there takes effect, with ACCEPT rules above it for trusted sources. By then the destination port has already been rewritten to the container port, so match that port or use -m conntrack --ctorigdstport for the published one, and persist the rules, for example in /etc/ufw/after.rules.
  • Behind Cloudflare, accept traffic on ports 80 and 443 only from Cloudflare’s published IP ranges. With Docker, that allowlist also has to live in DOCKER-USER or on a proxy bound to the host, for the reason above, and the ranges need updating when Cloudflare changes them.

What I did in the incident

I changed and isolated the origin IP and limited the ways the server could be reached, and only then tuned the WAF and rate limiting in Cloudflare. The case study and the note on Cloudflare and exposed origins cover the rest of the incident.

See also: Cloudflare does not protect a directly reachable origin, CI/CD and Docker deployment.

To talk about a similar setup or problem, use the contact page.

Related project

Related case study: