The cf-ufw-sync script additively synchronizes a UFW allowlist with Cloudflare's published IP ranges, with validation and dry-run support.
If your origin sits behind Cloudflare, you probably want ports 80/443 reachable only from Cloudflare's ranges. Those ranges change. Most published recipes solve this by flushing the firewall and rebuilding it from a freshly fetched list. That works right up until the fetch returns a truncated file, a captive portal page, or nothing at all — and then the rebuild removes the rules that were keeping your own traffic alive.
cf-ufw-sync takes the opposite position.
1. Additive only. The script never deletes a UFW rule. Ranges you allow that Cloudflare no longer
publishes are written to the log as STALE-ALERT for a human to review. An extra allow is a small
problem; a removed allow can be an outage.
2. Refuse implausible input. Cloudflare publishes roughly 15 IPv4 and 7 IPv6 ranges. If the fetched list parses to fewer than 5 or more than 60 entries, the script aborts and changes nothing. This is the check that catches a truncated response before it becomes a firewall change.
3. Dry run is first-class. --dry-run prints every rule it would add and touches nothing, so you
can see the diff before it exists.
curl -fsSLO https://raw.githubusercontent.com/gexiro-global/cloudflare-ufw-sync/main/cf-ufw-sync.sh
chmod +x cf-ufw-sync.sh
sudo mv cf-ufw-sync.sh /usr/local/bin/cf-ufw-syncRequires bash, curl, ufw, flock, python3, and iptables/ip6tables (read-only,
for the stale check).
flock and python3 are not optional: the run is serialised with a lock, and address
validation is delegated to python's ipaddress rather than a hand-rolled regex. If either is
missing the script refuses to start rather than proceeding without the property it promises.
cf-ufw-sync --dry-run # show what would change
sudo cf-ufw-sync # sync IPv4 on ports 80 and 443
sudo cf-ufw-sync --ipv6 # include IPv6 ranges
sudo cf-ufw-sync --ports "443" # HTTPS onlyRun it from cron once a day:
17 4 * * * /usr/local/bin/cf-ufw-sync| Variable | Default | Meaning |
|---|---|---|
CF_UFW_LOG |
/var/log/cf-ufw-sync.log |
Log destination. Use - for stdout. |
CF_UFW_PORTS |
80 443 |
Default port list. |
CF_UFW_LOCK_DIR |
/run/lock/cf-ufw-sync (root) |
Directory (not a file) flock'd to serialise concurrent runs. Must be a non-symlink directory you own; opened read-only, so it is never truncated. |
2026-01-01T04:17:00Z ADDED 198.51.100.0/24 port 443
2026-01-01T04:17:00Z STALE-ALERT 203.0.113.0/24 review-manually
2026-01-01T04:17:00Z OK ranges=15 added=1 add_failed=0 stale=1 dry_run=0
Exit codes: 0 success, 1 fetch or sanity failure (nothing changed) or one or more ufw rules failed to install, 2 usage error. A rule that fails to install is logged as ADD-FAIL, is not counted as an addition, and fails the run — a firewall sync that silently reports success while changing nothing is worse than one that errors.
- It does not remove firewall rules, ever. Cleaning up stale ranges is a manual decision.
- It does not configure UFW's default policy. If your default inbound policy is
allow, adding Cloudflare ranges achieves nothing — setufw default deny incomingyourself, deliberately. - It does not protect ports other than the ones you name.
- It does not verify that the traffic reaching you actually came through Cloudflare. IP allowlisting is one layer; consider Cloudflare Authenticated Origin Pulls for the rest.
- It is not a substitute for keeping your origin's real address out of public DNS history.
This script edits a firewall. Run --dry-run first. If you administer the machine over SSH on a port
covered by --ports, understand that adding restrictive allows to a deny incoming policy can cut your
own session. Keep an out-of-band console available the first time you run it.
./tests/run_tests.shThe test runs fully offline against a synthetic fixture in examples/ — no network, no firewall access.
Apache-2.0. See LICENSE.
Built and maintained by Gexiro Global Enterprises Ltd.
Part of the Gexiro open-source toolkit.