
The No-Stress Guide to Migrating Your VPS Hosts to Cloudflare Load Balancers (Step-by-Step)
Sep 16
7 min read
0
7
0

If you run workloads across one or more VPS servers (IIS/ASP.NET, Nginx, Node, PHP, doesn’t matter) and you want automatic failover, smart routing, and global anycast IPs, Cloudflare Load Balancing (LB) is one of the cleanest ways to get there.
This article is a field-tested, extremely detailed walkthrough you can follow end-to-end. It also includes fixes for the most common gotchas, wildcards, DNS, health checks, and “why is it still NXDOMAIN?!” moments, so you don’t get stuck.
What you’ll build
Two (or more) VPS origins behind a Cloudflare Load Balancer
One set of pools (one origin per server) with attached Monitors (health checks)
Wildcard hostname support (so many subdomains like api, admin, portal, logapi all work via the same LB)
Clean DNS that resolves to Cloudflare Anycast IPs, with easy testing & rollout
Optional: API/Terraform snippets to automate the whole thing
Cloudflare terms refresher: an origin is a single backend server; a pool is a group of one or more origins; a load balancer is the public endpoint (hostname) that chooses a healthy pool/origin. (Cloudflare Docs)
Prerequisites
Two VPS servers (call them Server A and Server B), each capable of serving the same app(s).
Admin access to your domain registrar and to your Cloudflare account.
A simple health endpoint on each server (examples below) that returns 200 OK.
Step 1: Switch your domain to Cloudflare DNS (full setup)
Most Cloudflare features (including Load Balancing) require Cloudflare to be authoritative for your domain.
In Cloudflare, add your zone (domain) and note the nameservers Cloudflare assigns you.
At your registrar, change your domain’s nameservers to those Cloudflare ones.
Wait for propagation. (You can keep your production DNS records in sync during cutover.)
Cloudflare’s docs: Full setup / change nameservers. (Cloudflare Docs)
Gotcha: After you switch to Cloudflare NS at the registrar, delete any old NS records that still exist inside your zone (in Cloudflare → DNS tab). Delegation happens at the registrar; zone-level NS pointing to your old provider can confuse resolvers.
Step 2: Add/confirm a health endpoint on each server
Cloudflare LB needs a URL it can ping to decide if an origin is healthy.
IIS (Windows) minimal “always 200” health site
Create folder C:\inetpub\healthcheck with index.html containing OK.
In IIS Manager → Sites → Add Website
Binding: http, Port 80, Hostname: (leave blank)
Point to the folder above.
Now http://<server-ip>/ returns 200 OK. That’s perfect for a Monitor path of /.
Nginx
location /health {
return 200 'OK';
}
Node/Express
app.get('/health', (req, res) => res.status(200).send('OK'));
Prefer an app-level /health if you want to catch app crashes (not just “server up”). Otherwise, a static “always 200” site is fine for basic host liveness.
Step 3: Create your Pools (one per server)
In Cloudflare: Traffic → Load Balancing → Pools → Create pool
pool-a → Origin = Server A IP (enable, weight 1)
pool-b → Origin = Server B IP (enable, weight 1)
(You can run single-server initially with just pool-a, then add pool-b later.) (Cloudflare Docs)
Step 4: Create a Monitor (health check) and attach to pools
In Cloudflare: Traffic → Load Balancing → Monitors → Create monitor
Type: HTTP (or HTTPS if your origin speaks TLS)
Path: /health (or /)
Port: 80 (or 443 for HTTPS)
Expected codes: 200 (or a range like 200-399)
Timeout/Interval: e.g., 5s / 30s
Regions: near your users/servers (e.g., “Southern Africa” + another region)
Then attach the monitor to each pool (Edit pool → Monitor: your monitor). (Cloudflare Docs)
Tip: If your health endpoint requires a specific Host header (virtual hosting), set a Host header on the monitor so your server routes correctly. Cloudflare’s API/docs note you can and often should set it. (Cloudflare Docs)
Troubleshooting: If your monitor shows “Response code mismatch,” either change your endpoint to return 200 or expand Expected codes. (Cloudflare Docs)
Step 5: Create the Load Balancer (your public hostname)
In Cloudflare: Traffic → Load Balancing → Create load balancer
Hostname:
Use a wildcard like *.yourdomain.com if you want all subdomains (e.g., api, admin, portal, support) to share the LB.
Default/Pools: include pool-a and pool-b.
Steering policy: “Least outstanding requests” or “Weighted”.
Session affinity: Enable only if needed (sticky sessions).
Fallback pool: set to the other pool (or the same pool if you only have one origin to start with).
When you save, Cloudflare automatically creates the DNS record for that hostname, so you don’t add an A/CNAME manually for the same name. (Cloudflare Docs)
Wildcard note: Cloudflare supports wildcard DNS at the first label (e.g., *.example.com). That will cover api.example.com, admin.example.com, etc. (Cloudflare Docs)
Step 6: DNS wiring: what to expect (and what not to do)
You do not add a CNAME for api if your LB hostname is *.example.com. The LB already owns that label.
If you try to add a CNAME on a name that also has another record, you’ll get the classic “CNAME cannot coexist” error. This is by design (RFC 1034). (IETF Datatracker)
If you prefer explicit entries: you can create DNS → Add record → Load Balancer entries for api, admin, etc., each pointing at your LB. But with a wildcard LB hostname, you usually don’t need to
Step 7: Configure your web server host bindings (IIS/Nginx)
Cloudflare sends traffic with the original Host header (e.g., api.yourdomain.com).Make sure your server/site is actually bound to that hostname:
IIS: Site → Bindings… → add https :443 Hostname=api.yourdomain.com (and the others you use).
Nginx: server_name api.yourdomain.com; (and others).
If the base path / doesn’t exist in an API, a 404 is normal, hit a real route or expose /health.
Step 8: Secure your origins (recommended)
Set Cloudflare SSL/TLS mode to Full (strict) and use a Cloudflare Origin Certificate on your servers (or a valid cert you manage).
Optionally firewall your VPS to only allow inbound HTTP/HTTPS from Cloudflare’s IP ranges. (This ensures the world can’t bypass Cloudflare.)
Honour CF-Connecting-IP / X-Forwarded-For for real client IPs in logs/WAF.
Step 9: Test, verify, and understand propagation
Quick DNS checks
# Which IPs does this hostname return (system DNS)?
dig api.yourdomain.com
# Ask specific resolvers (handy during propagation)
dig api.yourdomain.com @8.8.8.8
dig api.yourdomain.com @1.1.1.1
Expect Cloudflare Anycast IPs (ranges like 104.*, 172.*, 188.*).Cloudflare automatically adds the LB DNS record for your hostname. (Cloudflare Docs)
If you see NXDOMAIN from one resolver but not another, it’s usually cached SOA/NS data expiring. Give it time based on TTLs and try again.
HTTP checks
# Does the request pass through Cloudflare?
curl -I https://api.yourdomain.com
# Look for: server: cloudflare, cf-ray: ...
Bypass local DNS cache (one-off)
# Force a specific Anycast IP (from a dig @8.8.8.8)
curl -I --resolve api.yourdomain.com:443:104.21.72.180 https://api.yourdomain.com
Step 10: Rollout patterns (single server → dual server)
Phase 1: Only pool-a (Server A). Set fallback to pool-a as well.
Phase 2: Add pool-b (Server B). Attach the same monitor.
Phase 3: Simulate failover: stop the app on A; confirm LB marks A unhealthy and routes to B.
Step 11: Map many apps to two origins (cleanly)
If both servers host multiple apps (api, admin, portal, logapi), don’t create separate origins for each app.Use the per-server pool design and a wildcard LB hostname. All subdomains then share the same two pools.Cloudflare returns only healthy origins in DNS answers, so your users hit a good server. (Cloudflare Docs)
Step 12: Troubleshooting checklist
CNAME conflict: “CNAME cannot coexist with other records” → remove the duplicate; LB creates its own record. (Spec rule from RFC 1034.) (IETF Datatracker)
NXDOMAIN on some resolvers: You likely changed nameservers recently; a resolver is still caching old SOA/NS. Wait for TTL or query a different resolver.
Monitor shows Unhealthy:
Origin not reachable, wrong port/path, Host header mismatch, or returning non-200.
Expand Expected codes or fix the endpoint. (Cloudflare Docs)
LB DNS record not visible: Confirm the LB Hostname is set in the LB config; Cloudflare creates the DNS record automatically for that hostname. (Cloudflare Docs)
Step 13: (Optional) Automate via API / Terraform
Create a Monitor (API shape excerpt)
{
"type": "https",
"path": "/health",
"method": "GET",
"timeout": 5,
"retries": 2,
"interval": 30,
"expected_codes": "200",
"header": {
"Host": ["api.yourdomain.com"]
}
}
It’s recommended to set a Host header for HTTP/HTTPS monitors. (Cloudflare Docs)
Terraform snippet (monitor)
resource "cloudflare_load_balancer_monitor" "health" {
account_id = var.account_id
expected_codes = "200"
method = "GET"
path = "/health"
type = "http"
timeout = 5
interval = 30
}
(See Terraform provider docs for all fields.) (Terraform Registry)
Pools & LB models
Pool/origin and steering model references (if you prefer API): (Cloudflare Docs)
Step 14: Ongoing care & feeding
Health Checks Analytics: inspect failure reasons, timeouts, and code mismatches. (Cloudflare Docs)
Regions: add multiple health-check regions for robust signal. (Cloudflare Docs)
Steering: switch to “weighted” if you want to canary a new server (e.g., 10% to pool-b).
Alerts: enable pool/origin health notifications so you know when a server degrades.
Copy-paste mini checklists
Fast build (single → dual)
Domain NS points to Cloudflare (registrar) ✔︎ (Cloudflare Docs)
Old zone-level NS records removed in Cloudflare DNS
Health endpoint returns 200 on each server
pool-a (Server A) with monitor attached
pool-b (Server B) added later, monitor attached
LB hostname set (e.g., *.yourdomain.com) → DNS created automatically (Cloudflare Docs)
Web server host bindings for the subdomains you’ll use
dig shows Cloudflare Anycast IPs; curl -I shows server: cloudflare
Monitor settings that “just work”
Type: HTTP(S)
Path: /health
Expected codes: 200 (or 200-399)
Timeout/Interval: 5s / 30s
Regions: 1–2 where your users are
(Optional) Host header set to your subdomain (Cloudflare Docs)
Why this architecture scales
You think in servers (origins) not per-app domains, which keeps origin count low.
A single wildcard LB hostname covers unlimited subdomains. (Cloudflare Docs)
Monitors and steering give you graceful failover and safer rollouts. (Cloudflare Docs)
Wrapping up this really long process
Once DNS has fully propagated, requests hit Cloudflare Anycast, your LB selects a healthy pool/origin, and your users experience fewer outages even during deploys or VPS hiccups. Most teams get tripped up on DNS delegation, CNAME coexistence, and monitors returning non-200. But if you follow the steps above, you’ll avoid those entirely (or fix them in minutes).






