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.
Docker bypasses your INPUT rules
Section titled “Docker bypasses your INPUT rules”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 DROPThe 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 → 200container: wget https://acme-v02.api.letsencrypt.org/directory → blockedTwo ways to fix it, both from the Docker docs’ examples:
- 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 abr-*bridge) passes. - Accept traffic from Docker’s bridges before the final drop, which works
without knowing the interface name:
-A DOCKER-USER -i docker0 -j ACCEPTand-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.
Changing rules safely
Section titled “Changing rules safely”- Live: insert above the final drop by line number
(
iptables -L DOCKER-USER --line-numbers -n, theniptables -I DOCKER-USER <n> …). - Persisted: edit
/etc/iptables/rules.v4in place, thensudo iptables-restore --test /etc/iptables/rules.v4. - Avoid
netfilter-persistent save(rewrites the file from live tables, including Docker’s own chains) andreload(can wipe Docker’s rules until Docker restarts). - No trailing comments.
-A … -j DROP # notemakesiptables-restorefail withBad 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 --testinside a throwaway container with--cap-add NET_ADMIN— it has its own network namespace.
Verify from outside
Section titled “Verify from outside”From any non-Cloudflare machine, once Traefik listens:
nc -vz -G 8 <server-ip> 443 # expect: Operation timed out