Skip to content

The firewall and Docker

The goal: only Cloudflare can reach ports 80 and 443, so nobody can bypass Cloudflare and so the CF-Connecting-IP header can be trusted.

From the Docker docs: ports that Docker publishes bypass ufw, firewalld and the INPUT chain. Published traffic is DNAT-ed and goes through FORWARD; the only place to filter it yourself is the DOCKER-USER chain, which Docker evaluates before its own rules.

So “allow Cloudflare on 443 in INPUT” does nothing for Traefik’s published port. The rules belong in DOCKER-USER:

-A DOCKER-USER -i lo -j ACCEPT
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
# container <-> container
-A DOCKER-USER -s 172.16.0.0/12 -d 172.16.0.0/12 -j ACCEPT
# one pair per Cloudflare range
-A DOCKER-USER -s <cloudflare-range> -p tcp -m tcp --dport 443 -j ACCEPT
-A DOCKER-USER -s <cloudflare-range> -p tcp -m tcp --dport 80 -j ACCEPT
# everything else from the internet (see below)
-A DOCKER-USER -i <ext_if> -j DROP

The ranges come from https://www.cloudflare.com/ips-v4 (and /ips-v6).

The rule that blocked every container’s outbound traffic

Section titled “The rule that blocked every container’s outbound traffic”

The chain originally ended in a plain -A DOCKER-USER -j DROP. That drops every forwarded packet not matched above — including connections that containers start. Traefik couldn’t reach Let’s Encrypt or the Cloudflare API:

host: curl https://acme-v02.api.letsencrypt.org/directory → 200
container: wget https://acme-v02.api.letsencrypt.org/directory → blocked

Two ways to fix it, both from the Docker docs’ examples:

  1. Scope the final drop to the public interface — -A DOCKER-USER -i <ext_if> -j DROP. Internet traffic is still dropped unless it’s from Cloudflare; container egress (arriving on a br-* bridge) passes.
  2. Accept traffic from Docker’s bridges before the final drop, which works without knowing the interface name: -A DOCKER-USER -i docker0 -j ACCEPT and -A DOCKER-USER -i br-+ -j ACCEPT.

Match the interface, not a private source range — a spoofed private source arriving on the public interface must not get through.

  • Live: insert above the final drop by line number (iptables -L DOCKER-USER --line-numbers -n, then iptables -I DOCKER-USER <n> …).
  • Persisted: edit /etc/iptables/rules.v4 in place, then sudo iptables-restore --test /etc/iptables/rules.v4.
  • Avoid netfilter-persistent save (rewrites the file from live tables, including Docker’s own chains) and reload (can wipe Docker’s rules until Docker restarts).
  • No trailing comments. -A … -j DROP # note makes iptables-restore fail with Bad argument '#'. A rules file in the infra repo had never loaded because of this. Put comments on their own lines.
  • Test a rules file without touching the host: run iptables-restore --test inside a throwaway container with --cap-add NET_ADMIN — it has its own network namespace.

From any non-Cloudflare machine, once Traefik listens:

Terminal window
nc -vz -G 8 <server-ip> 443 # expect: Operation timed out