Balance Traffic Across Data Centers with Cloudflare Load Balancer
Configure health checks and geo steering so traffic fails over between regional data centers automatically.
What you'll build
A Cloudflare Load Balancer in front of two regional data centers, with HTTPS health checks and geo steering: European visitors hit your EU origin, North American visitors hit your US origin, and if either data center goes down, its traffic automatically fails over to the other — no DNS edits, no pager-driven cutover.
graph LR
U[Visitor] --> LB[Cloudflare LB<br>app.example.com<br>steering_policy: geo]
LB -->|WNAM / ENAM| US[Pool us-east<br>198.51.100.10]
LB -->|WEU / EEU| EU[Pool eu-west<br>203.0.113.20]
US -.->|us-east unhealthy| EU
EU -.->|eu-west unhealthy| US
Prerequisites
Verified against the Cloudflare API v4 and Load Balancing docs as of July 2026.
- A Cloudflare account with a zone (domain) already on Cloudflare, plus the Load Balancing add-on enabled for that zone (Traffic → Load Balancing → Enable; starts at $5/month). Region/country geo steering is configurable via the dashboard and API — confirm it's included in your subscription tier before you start; PoP-level steering is Enterprise-only.
- Two origin servers in different regions, each serving your app over HTTPS. Any Linux + nginx/Caddy/whatever works; you just need a health endpoint (below).
- An API token (My Profile → API Tokens → Create Token) with Account → Load Balancing: Monitors and Pools → Edit and Zone → Load Balancers → Edit on your zone. Monitors and pools live at the account level; the load balancer itself is zone-level, so you need both scopes.
curland jq (any recent version) on your workstation.
On each origin, expose a health endpoint that also identifies the data center — you'll use it to watch failover happen. For nginx, add this inside your server block (change us-east to eu-west on the other box), then sudo nginx -s reload:
location /healthz {
add_header x-dc us-east always;
return 200 "ok us-east\n";
}
1. Set up your shell environment
Export your token, then pull the zone and account IDs from the API so the later calls can reference them:
export CF_API_TOKEN="your-token-here"
export ZONE_ID=$(curl -s -H "Authorization: Bearer $CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones?name=example.com" | jq -r '.result[0].id')
export ACCOUNT_ID=$(curl -s -H "Authorization: Bearer $CF_API_TOKEN" \
"https://api.cloudflare.com/client/v4/zones?name=example.com" | jq -r '.result[0].account.id')
echo "$ZONE_ID / $ACCOUNT_ID"
Both should print 32-character hex IDs. If either is null, fix your token before going further (see Troubleshooting).
2. Create a health monitor
A monitor defines what "healthy" means. Cloudflare probes each pool endpoint on the interval you set and marks it down after the configured retries fail. One monitor can be shared by every pool:
MONITOR_ID=$(curl -s -X POST \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/load_balancers/monitors" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{
"type": "https",
"description": "App health check",
"method": "GET",
"path": "/healthz",
"header": { "Host": ["app.example.com"] },
"expected_codes": "200",
"interval": 60,
"retries": 2,
"timeout": 5,
"follow_redirects": false,
"allow_insecure": false
}' | jq -r '.result.id')
echo "$MONITOR_ID"
The explicit Host header makes probes hit the right virtual host even though they're sent to the origin's raw IP. interval: 60 keeps probe traffic modest; non-Enterprise plans allow down to 15 seconds (10 on Enterprise), but every extra health-check region multiplies that traffic against your origins.
3. Create one pool per data center
Pools group the endpoints in one location. check_regions controls where probes originate from — probe each pool from the region that will actually use it:
POOL_US=$(curl -s -X POST \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/load_balancers/pools" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "{
\"name\": \"us-east\",
\"description\": \"US East data center\",
\"origins\": [
{ \"name\": \"us-east-1\", \"address\": \"198.51.100.10\", \"enabled\": true, \"weight\": 1 }
],
\"monitor\": \"$MONITOR_ID\",
\"check_regions\": [\"ENAM\"]
}" | jq -r '.result.id')
POOL_EU=$(curl -s -X POST \
"https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/load_balancers/pools" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "{
\"name\": \"eu-west\",
\"description\": \"EU West data center\",
\"origins\": [
{ \"name\": \"eu-west-1\", \"address\": \"203.0.113.20\", \"enabled\": true, \"weight\": 1 }
],
\"monitor\": \"$MONITOR_ID\",
\"check_regions\": [\"WEU\"]
}" | jq -r '.result.id')
echo "US: $POOL_US EU: $POOL_EU"
Swap in your real origin IPs (or hostnames). Note the API field is still named origins even though the dashboard now calls them endpoints.
4. Create the geo-steered load balancer
Now tie it together. With steering_policy: "geo", region_pools maps Cloudflare's 13 geographic regions to an ordered list of pool IDs — order is failover priority, so listing the remote pool second is what gives you automatic cross-region failover:
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/load_balancers" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data "{
\"name\": \"app.example.com\",
\"steering_policy\": \"geo\",
\"proxied\": true,
\"default_pools\": [\"$POOL_US\", \"$POOL_EU\"],
\"fallback_pool\": \"$POOL_US\",
\"region_pools\": {
\"WNAM\": [\"$POOL_US\", \"$POOL_EU\"],
\"ENAM\": [\"$POOL_US\", \"$POOL_EU\"],
\"WEU\": [\"$POOL_EU\", \"$POOL_US\"],
\"EEU\": [\"$POOL_EU\", \"$POOL_US\"]
}
}" | jq '.result | {id, name, steering_policy, proxied, enabled}'
Regions you don't map (say, OC or SAS) fall through to default_pools, and fallback_pool is the last resort served when every pool is down. proxied: true routes requests through Cloudflare's edge, so failover isn't at the mercy of DNS caching. The full region code list (WNAM, ENAM, WEU, EEU, ME, NAF, SAF, NEAS, SEAS, SAS, OC, NSAM, SSAM) is in the Regions API reference.
Verify it works
Give the monitor one interval (~60s) to run its first checks, then confirm both pools report healthy:
curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/load_balancers/pools" \
-H "Authorization: Bearer $CF_API_TOKEN" \
| jq -r '.result[] | "\(.name): healthy=\(.healthy)"'
Expected output:
eu-west: healthy=true
us-east: healthy=true
Then hit the app and check which data center answered:
curl -si https://app.example.com/healthz | grep -i -e x-dc -e '^ok'
From Europe you should see x-dc: eu-west; from North America, x-dc: us-east.
Now run a failover drill. Stop the web server in one data center (sudo systemctl stop nginx on the US origin). Within roughly interval × (retries + 1) — about three minutes with these settings — the pool flips to healthy=false in the command above, and the same curl from a US vantage point starts returning x-dc: eu-west. Start nginx again and traffic drains back automatically once the pool passes its checks. The Load Balancing dashboard's event log shows each transition with the failure reason.
Troubleshooting
{"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}— your token is missing one of the two scopes. Monitor and pool endpoints need the account-level "Load Balancing: Monitors and Pools — Edit" permission; a token with only zone permissions fails exactly like this. Edit the token and re-run.- Pool shows unhealthy but the site loads fine in your browser — the docs are blunt here: "Ensure your firewall or web server does not block or rate limit our health monitors." Allow Cloudflare's IP ranges at your origin firewall, and check the event log's failure reason — a 403 or wrong status usually means the probe hit the default vhost because the monitor's
Hostheader doesn't match your server config. - Geo steering seems ignored after adding custom rules — per Cloudflare's docs, "Custom load balancing rules are incompatible with Geo steering." Pick one: either rules-based steering or
steering_policy: "geo", not both. - Failover works but some clients hit the dead origin for a while — you created the load balancer with
proxied: false. DNS-mode load balancers are subject to resolver TTL caching, and Cloudflare itself caches resolved IPs for up to five seconds. Setproxied: true, or accept the TTL lag.
Next steps
Wire pool health into Cloudflare notifications so a pool going down pages you even though failover already handled it. Explore other steering policies — dynamic_latency routes on measured round-trip time instead of static geography, and least_outstanding_requests balances on load. If you manage infrastructure as code, the same monitors, pools, and load balancers map cleanly onto the Cloudflare Terraform provider, which beats hand-run curl once you're past the experimentation stage.
Sources & further reading
- Create a load balancer — developers.cloudflare.com
- Create a monitor — developers.cloudflare.com
- Create a pool — developers.cloudflare.com
- Geo steering — developers.cloudflare.com
- Load Balancers - Create - API reference — developers.cloudflare.com
- Load Balancing FAQ — developers.cloudflare.com
Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.
Discussion 0
No comments yet
Be the first to weigh in.