Restricting Docker Ports to Cloudflare IPs with UFW
The Problem
I run a multi-tenant app behind Cloudflare on a VPS. The app lives inside Docker containers, Caddy is the reverse proxy, and ports 80/443 are published via Docker Compose.
The issue: bots were hitting the VPS IP directly, bypassing Cloudflare entirely. I wanted to allow traffic only from Cloudflare IP ranges. So I added a UFW rule:
ufw allow from 173.245.48.0/20 to any port 80,443 proto tcp
It did absolutely nothing. Bots kept getting through.
Why UFW Doesn’t Work with Docker
Docker manipulates iptables directly — it uses the nat table and PREROUTING/FORWARD chains. UFW only manages the INPUT and OUTPUT chains. These never overlap for containerized traffic.
Standard ufw allow rules only affect traffic destined for the host itself (like SSH). Traffic routed to a Docker container takes a different path that UFW never sees.
The Fix: ufw-docker + ufw route
ufw-docker solves this by injecting a single rule into Docker’s DOCKER-USER chain that routes all container-bound traffic through UFW’s forward chain:
-A DOCKER-USER -j ufw-user-forward
After this, UFW’s default forward policy (DROP) blocks all public access to containers. You then punch holes for Cloudflare using ufw route allow — which creates FORWARD chain rules, not INPUT rules.
Setup
1. Install ufw-docker
wget -O /usr/local/bin/ufw-docker \
https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
chmod +x /usr/local/bin/ufw-docker
ufw-docker install
ufw-docker install-service # handles Docker restarts
ufw reload
The install-service step matters — Docker recreates the DOCKER-USER chain empty on every restart. The systemd service reloads UFW automatically so your rules survive container restarts.
2. Allow Cloudflare IPs
for ip in $(curl -sf https://www.cloudflare.com/ips-v4); do
ufw route allow from "$ip" to any port 80,443 proto tcp
done
for ip in $(curl -sf https://www.cloudflare.com/ips-v6); do
ufw route allow from "$ip" to any port 80,443 proto tcp
done
3. Automate the IP refresh
Cloudflare’s IP ranges occasionally change. Script at /usr/local/bin/update-cloudflare-ufw.sh:
#!/usr/bin/env bash
set -euo pipefail
export PATH="/usr/sbin:/usr/bin:/sbin:/bin"
LOG_TAG="cloudflare-ufw"
CF_IPV4=$(curl -sf https://www.cloudflare.com/ips-v4) || { logger -t "$LOG_TAG" "Failed to fetch IPv4"; exit 1; }
CF_IPV6=$(curl -sf https://www.cloudflare.com/ips-v6) || { logger -t "$LOG_TAG" "Failed to fetch IPv6"; exit 1; }
IPV4_COUNT=$(echo "$CF_IPV4" | wc -l)
if [ "$IPV4_COUNT" -lt 10 ]; then
logger -t "$LOG_TAG" "Suspiciously few IPv4 ranges ($IPV4_COUNT), aborting"
exit 1
fi
# Delete existing route rules for ports 80,443
{
ufw status numbered | grep -E "80,443.*ALLOW FWD" | grep -oP '^\[\s*\K[0-9]+' | sort -rn | while read -r num; do
ufw --force delete "$num"
done
} || true
# Add fresh Cloudflare route rules
for ip in $CF_IPV4; do
ufw route allow from "$ip" to any port 80,443 proto tcp
done
for ip in $CF_IPV6; do
ufw route allow from "$ip" to any port 80,443 proto tcp
done
logger -t "$LOG_TAG" "Updated: $IPV4_COUNT IPv4 + $(echo "$CF_IPV6" | wc -l) IPv6 ranges"
A few notes about the script:
export PATHis required — cron runs with a minimal environment- The sanity check (
IPV4_COUNT -lt 10) aborts if Cloudflare’s endpoint returns garbage, preventing a rule wipeout || trueon the delete block preventsset -euo pipefailfrom aborting when there are no existing rules to remove
Cron entry at /etc/cron.d/cloudflare-ufw:
0 3 * * * root /usr/local/bin/update-cloudflare-ufw.sh
Check it’s running with:
journalctl -t cloudflare-ufw --no-pager -n 10
Verification
# Rules look right
ufw status | grep "ALLOW FWD" | wc -l # should match CF IP count (15 IPv4 + 7 IPv6 = 22)
# Direct IP access should time out
curl -I http://<vps-ip>
# Cloudflare-proxied domain should still work
curl -I https://yourdomain.com
References
- chaifeng/ufw-docker — the tool that bridges Docker’s iptables with UFW
- Docker: Packet filtering and firewalls — why Docker ignores UFW by default
- Cloudflare IP Ranges — the allowlist source of truth