Back to Home

Restricting Docker Ports to Cloudflare IPs with UFW

#docker #ufw #cloudflare #firewall #security #linux

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 PATH is 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
  • || true on the delete block prevents set -euo pipefail from 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

Comments