SELFHOST.COMPUTER

Docker ignores your ufw firewall: how to check and fix it

#docker#firewall#security

If you run Docker on a server with ufw, your firewall probably isn't protecting your containers. Docker publishes ports through its own firewall rules, and those take effect before ufw ever sees the traffic. This is the "Docker ignores ufw" problem. Below: why it happens, how to check whether you're exposed, and three fixes, from best to last resort.

It applies to any setup where a compose file has a ports: section. If you followed the first 15 minutes on a fresh VPS checklist and then started containers, read on.

Why Docker ignores ufw

Docker's own documentation says it plainly: when you publish a container's ports, the traffic is diverted before it goes through your ufw settings. Docker routes container traffic in the nat table, so packets are redirected before they reach the INPUT and OUTPUT chains that ufw uses. The docs' summary is that Docker and ufw use firewall rules in ways that make them incompatible with each other.

The same page says publishing ports is insecure by default: a port published with -p 8080:80 is mapped on any address of the host, so it's reachable from the outside world, not just from the host.

That's why a rule like ufw default deny incoming doesn't help here. ufw still works for services running directly on the host, like SSH. It just doesn't see published container ports.

Check what is exposed

On the server, list the published ports and their bind addresses:

docker ps --format "table {{.Names}}\t{{.Ports}}"

A port shown as 0.0.0.0:8080->80/tcp or [::]:8080->80/tcp is open to everyone. 127.0.0.1:8080->80/tcp is local only. The real test is a scan from another machine, because that is what an attacker sees:

nmap -Pn -p 1-10000 YOUR_SERVER_IP

Run it from your laptop or another server, not from the box itself. Also check IPv6 if your server has an IPv6 address, since Docker maps ports there too.

Fix 1: don't publish the port

The best fix is to have nothing to leak. A reverse proxy such as Caddy runs in the same Docker network as your apps and reaches them by container name. Only the proxy publishes ports 80 and 443. Apps like Uptime Kuma or Vaultwarden need no ports: section at all. The Docker and Caddy setup walks through this. It also means a wrongly configured ufw can't expose anything by accident.

Fix 2: bind published ports to localhost

Sometimes you do need a port on the host, for example for a database you reach over an SSH tunnel, or an app that your proxy runs outside Docker. Put the loopback address in front of the mapping:

services:
  app:
    image: your/app:tag
    ports:
      - "127.0.0.1:8080:80"
      - "[::1]:8080:80"

Docker's docs confirm that with the localhost address in the publish flag, only the Docker host can reach the published port. The second line does the same for IPv6. Check the docker ps output again afterwards.

One caveat from the same page: in Docker releases older than 28.0.0, hosts in the same layer 2 segment (for example, on the same switch) could still reach ports published to localhost. On a VPS that mostly matters if you share a network with other customers' machines. Keeping Docker updated closes it.

Fix 3: filter in the DOCKER-USER chain

If a port must be published on a public address but only certain IPs should reach it, Docker provides the DOCKER-USER chain. Docker's docs call it a placeholder for your own rules, processed before Docker's own rules. Rules you append to FORWARD yourself are processed after Docker's rules, so they come too late. That's the other reason ufw rules don't work.

The documented pattern to allow only one address (replace ext_if with your server's external interface, shown by ip -br addr, and 203.0.113.10 with your own IP):

sudo iptables -I DOCKER-USER -i ext_if ! -s 203.0.113.10 -j DROP

Three details from Docker's docs matter:

  • The rule matches packets after the port mapping happened. Inside DOCKER-USER you see the container's internal IP and port, not the public ones. To match the original port, you need the conntrack extension (-m conntrack --ctorigdstport), which the docs say can reduce performance.
  • Containers that make outgoing requests need their replies to get through. Docker suggests adding -m state --state RELATED,ESTABLISHED -j ACCEPT to DOCKER-USER and says it must be placed before the DROP rule. Since -I inserts at the top of the chain, run the DROP command first and the ACCEPT command second.
  • Docker's docs also say not to modify the rules Docker itself creates. Only add rules to DOCKER-USER.
sudo iptables -I DOCKER-USER -i ext_if ! -s 203.0.113.10 -j DROP
sudo iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPT
sudo iptables -L DOCKER-USER -n --line-numbers

These commands do not survive a reboot on their own. You need to load them again at boot, after Docker has started, for example from a small systemd service that runs after docker.service (the systemd generator can write one). IPv6 needs the same rules with ip6tables. Test from another machine after every change; this is easy to get subtly wrong.

Docker's docs state that Docker is only compatible with iptables-nft and iptables-legacy. Firewall rules written with nft directly aren't supported on a system with Docker installed. Keep this in mind before replacing ufw with a hand-written nftables ruleset; the firewall generator has notes on running its nftables output next to Docker.

Things that don't work

  • Adding more ufw deny rules for the container's port. Same problem: the traffic never reaches those rules.
  • Setting "iptables": false in daemon.json. Docker's docs say this isn't appropriate for most users and is likely to break container networking. Without replacement rules, containers can't reach the internet through masquerading.
  • Relying on the cloud provider's network firewall by accident. If your provider has one, it's a good extra layer. Don't assume it's switched on, and check with a scan.

The default I'd use

New stacks get no ports: except on the reverse proxy. Anything that needs a host port is bound to 127.0.0.1. ufw stays on for SSH and the proxy's 80 and 443. Use DOCKER-USER only when a service has to be public but limited to known IPs, and scan from outside to confirm it works. The compose generator builds stacks in this shape.

Next step: set up the proxy with the Docker and Caddy post, then lock down logins with SSH hardening and fail2ban.

< all posts · netcup voucher codes

Keep reading