This article was originally published on Jo4 Blog. Go grep your infra repo for 173.245.48.0/20. If that string is in a firewall rule, a Terraform module, or a doctl invocation, you have a time bomb. Cloudflare publishes the canonical list of edge IPs at two URLs, and they change. Not often — but often enough that a hardcoded copy quietly goes stale, and on the day a new CIDR ships, a slice of your real visitors gets dropped at the origin firewall with no log entry that obviously means "we're filtering them out." We had this problem on the DigitalOcean firewall in front of our self-hosted monitoring droplet. The fix isn't complicated, but it has to be wired through the deploy step that actually applies the firewall, not a sidecar cron. Here's the pattern. Why Hardcoded CIDRs Go Stale Cloudflare's list at https://www.cloudflare.com/ips-v4 and https://www.cloudflare.com/ips-v6 is the source of truth for which edge IPs may legitimately terminate TLS for your zone. They publish about 15 v4 ranges and 7 v6 ranges at the time of writing. That list is stable for long stretches and then changes — a new range gets added when CF expands a POP, an old block gets retired in a network migration, sometimes a chunk shifts to a different aggregation prefix. The standard hardcoded approach looks fine the day you ship it and decays silently. Six months in, somebody adds you to a new CF range, your DO firewall says "not on the list," and the request never makes it past the origin firewall. From the user's perspective: random subset of traffic, no useful error, retries sometimes work (because they hit a different CF edge on the way back), nothing in your application logs because the packet never reached your app. This is the worst class of bug — invisible to your observability stack because your observability stack is downstream of the failure. The right fix is to make drift impossible by construction. Fetch the list live every time you write the firewall. The Workflow Step This is the actual step from our DigitalOcean-490-Impress-Bootstrap workflow. It runs every time we bootstrap or re-bootstrap the monitoring droplet, and the firewall rules are reconciled atomically against whatever Cloudflare published thirty seconds ago. First the SSH base rule, sourced from a GitHub variable that holds operator home IPs: FIREWALL_NAME="jo4-impress-firewall" DROPLET_ID=$(doctl compute droplet list --format Name,ID --no-header \ | awk -v n="$DROPLET_NAME" '$1==n {print $2}') # Build base tcp/22 rule from operator allow-list (GH variable). # Format `1.2.3.4/32,5.6.7.8/32` → `address:1.2.3.4/32,address:5.6.7.8/32`. OPERATOR_SSH=$(echo "$HOME_IPS_FOR_SSH" | tr ',' '\n' | grep -v '^$' \ | sed 's/^/address:/' | paste -sd, -) if [ -z "$OPERATOR_SSH" ]; then echo "::error::HOME_IPS_FOR_SSH parsed to empty — refusing to deploy a no-SSH firewall (would lock everyone out)" exit 1 fi INBOUND_22="protocol:tcp,ports:22,${OPERATOR_SSH}" Now the live Cloudflare fetch and sanity check: # Fetch live Cloudflare CIDRs for tcp/443. Fail loudly if either # endpoint is unreachable — we'd rather block this workflow run # than silently apply a half-empty allow-list. CF_V4=$(curl -fsSL --max-time 10 https://www.cloudflare.com/ips-v4) CF_V6=$(curl -fsSL --max-time 10 https://www.cloudflare.com/ips-v6) if [ -z "$CF_V4" ] || [ -z "$CF_V6" ]; then echo "::error::Cloudflare published-IP fetch returned empty" exit 1 fi V4_COUNT=$(echo "$CF_V4" | grep -c .) V6_COUNT=$(echo "$CF_V6" | grep -c .) if [ "$V4_COUNT" -lt 10 ] || [ "$V6_COUNT" -lt 5 ]; then echo "::error::Cloudflare CIDR fetch implausibly small: v4=$V4_COUNT v6=$V6_COUNT" exit 1 fi echo "📡 Cloudflare published $V4_COUNT v4 + $V6_COUNT v6 ranges" INBOUND_443=$(printf '%s\n%s\n' "$CF_V4" "$CF_V6" | grep -v '^$' \ | sed 's/^/address:/' | paste -sd, -) INBOUND_443="protocol:tcp,ports:443,${INBOUND_443}" Two curl calls. A line count per family. A floor. Then a single comma-joined rule string in the format doctl wants. Five seconds of work in the runner, and the rule we're about to ship is sourced from CF's live publication rather than from somebody's memory of CF's publication on the day they wrote the workflow. Finally the atomic apply: # Always reconcile (cheap, idempotent). `firewall update` is # atomic PUT — replaces all rules in one call, no remove-then-add # gap. This also wipes any stale runner-IP rule from a prior # run whose cleanup step failed. echo "♻️ Reconciling rules on existing $FIREWALL_NAME ($FIREWALL_ID)..." doctl compute firewall update "$FIREWALL_ID" \ --name "$FIREWALL_NAME" \ --inbound-rules "$INBOUND_22 $INBOUND_443" \ --outbound-rules "$OUTBOUND" \ --droplet-ids "$DROPLET_ID" That's the whole thing. Every run, every bootstrap, every redeploy: the firewall converges on whatever CF published just now. The Sanity Floor The two-line floor is the only piece of this that needs defending, because it looks defensive-in-a-way-that-might-be-paranoid: if [ "$V4_COUNT" -lt 10 ] || [ "$V6_COUNT" -lt 5 ]; then echo "::error::Cloudflare CIDR fetch implausibly small: v4=$V4_COUNT v6=$V6_COUNT" exit 1 fi We know CF publishes ~15 v4 and ~7 v6 ranges. The floor of 10/5 is "well below the real number, well above zero." The threat model isn't "CF is hacked and publishes a tiny list" — it's much more pedestrian: A transient blip at cloudflare.com returns a 200 with a partial body. curl -fsSL is happy because the status was 2xx. The CIDR list comes back truncated. A future change to the publication format — a comment header, a JSON wrapper, an HTML maintenance page — survives curl and starts feeding garbage into our grep-and-sed pipeline. A network hiccup mid-stream that delivers some lines and then EOFs cleanly. Without the floor, any of those scenarios deploys a firewall with a one- or two-CIDR allow-list. Result: we just locked out 95% of legitimate Cloudflare traffic. The floor catches the "looks like a successful fetch but the content is wrong" failure mode, which is the only mode where this whole system fails dangerously. If CF actually shrinks their published list below 10 v4 ranges, somebody needs to look at it manually anyway, and the workflow failing is the right way to find out. Why doctl firewall update Is Right The other detail worth slowing down on is the choice of doctl compute firewall update over the more obvious-looking remove-rules + add-rules pair. The DO API's update endpoint is an atomic PUT — it replaces the entire rule set in one call. There is no window where the firewall has the old rules removed but the new rules not yet added. The remove-then-add pattern has exactly that window. It might be 50ms. It might be 5 seconds if the API is slow. During that window, requests that should have been allowed by the new rules are denied because neither the old nor the new rules are in effect. With a high-traffic origin, "5 seconds of half-denied traffic" is a non-trivial number of failed user requests every time you reconcile. The PUT semantics also give us a free side-effect: any stale ephemeral rule from a prior run gets wiped. Our workflow also temporarily adds the runner's public IP to the SSH rule for the duration of the job (so the runner can ssh into the droplet), then revokes it in an if: always() cleanup step at job end. If that cleanup step ever fails — doctl API blip, runner killed mid-step — the stale runner-IP entry would normally accumulate. With atomic PUT reconcile, the next run's firewall update overwrites whatever was there with a fresh rule set built from scratch. Self-healing, no janitor cron needed. What This Replaces The old pattern is one of: Hardcoded list in the workflow. Decays. No alarm rings the day CF adds a new range. You find out from a customer. A separate cron that updates the firewall daily. Adds a moving part. Has its own credentials, its own failure modes, its own observability gap. The cron breaks, you don't notice, you're back to the hardcoded-and-stale problem with extra complexity on top. A Terraform module pinned to a CF data source. Closer, but only as fresh as your last terraform apply. If you only re-apply infra changes every few months, you've reintroduced the staleness window. The pattern above sidesteps all of them: the firewall converges on CF's live list every time the bootstrap workflow runs, and the bootstrap workflow runs every time we change anything about the droplet. Drift is bounded by deploy cadence, not by anybody remembering to check. There's nothing exotic here — two curl calls, a sanity check, one atomic doctl call — but the composition is the point. Live fetch + floor + PUT semantics gives you a firewall that's right by construction, not right by vigilance. How do you keep upstream IP allow-lists fresh? What's worked for your cloud + edge provider? Drop it in the comments. Building jo4.io — a URL shortener with analytics for developers who ship.