Cloudflare Worker Load Balancer with Health Checks (For Free)
A free, practical load balancer using Cloudflare Workers + Cloudflare Tunnel, with periodic health checks and automatic failover.
Cloudflare Load Balancer is great, but it’s a paid product. If your use case is “I have two replicas behind Cloudflare Tunnel and I just want failover if one goes down”, you can implement a simple edge load balancer using a Cloudflare Worker on the free tier.
The core idea is:
- You expose two different hostnames, one per tunnel (primary and replica).
- You expose a third hostname (the one you share with users) that points to the Worker.
- The Worker performs health checks and proxies each request to the best target.
Architecture (recommended)
You’ll usually want 3 hostnames:
service.yourdomain.com:- Points to the Worker (this is the public URL)
service-primary.yourdomain.com:- Cloudflare Tunnel pointing to your primary server
service-replica.yourdomain.com:- Cloudflare Tunnel pointing to your replica server
This solves the common Cloudflare Tunnel limitation: you can’t bind the same hostname to multiple tunnels, but you can bind different hostnames and put a Worker in front.
How the Worker works
- It keeps an in-memory
serverHealthmap with:healthy: last known health statuslastCheck: timestamp of the last health probe
- On each incoming request:
- It refreshes health status if the cache is older than
HEALTH_CHECK_INTERVAL. - It picks the first healthy server.
- If none are healthy, it falls back to the first server.
- It refreshes health status if the cache is older than
- It adds an
X-Served-Byheader for debugging. - If the proxy
fetchfails, it retries once against the other server.
Important detail: the cache is stored in the Worker isolate memory. That means it’s not a guaranteed global cache (it may reset on cold starts). For simple failover, this is usually fine.
The code
Replace the tunnel URLs below with your own.
// Cloudflare Worker Load Balancer with Health Check
// Configure your tunnel URLs here
const SERVERS = [
{
url: 'https://your-tunnel-1.yourdomain.com',
name: 'Server 1',
healthCheckPath: '/health' // or '/' if you don't have a specific endpoint
},
{
url: 'https://your-tunnel-2.yourdomain.com',
name: 'Server 2',
healthCheckPath: '/health'
}
]
// Settings
const HEALTH_CHECK_TIMEOUT = 5000 // 5 seconds
const HEALTH_CHECK_INTERVAL = 30000 // Check every 30 seconds
// Server status cache (kept by the Worker)
let serverHealth = {}
// Health check
async function checkHealth(server) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT)
const response = await fetch(server.url + server.healthCheckPath, {
method: 'GET',
signal: controller.signal,
headers: {
'User-Agent': 'Cloudflare-Worker-HealthCheck'
}
})
clearTimeout(timeoutId)
// Healthy if 2xx or 3xx
return response.status >= 200 && response.status < 400
} catch (error) {
console.log(`Health check failed for ${server.name}:`, error.message)
return false
}
}
// Pick an available server
async function getAvailableServer() {
// Refresh health check if needed
for (const server of SERVERS) {
const lastCheck = serverHealth[server.url]?.lastCheck || 0
const now = Date.now()
if (now - lastCheck > HEALTH_CHECK_INTERVAL) {
const isHealthy = await checkHealth(server)
serverHealth[server.url] = {
healthy: isHealthy,
lastCheck: now
}
}
}
// Pick the first healthy server
for (const server of SERVERS) {
if (serverHealth[server.url]?.healthy) {
return server
}
}
// If none are healthy, use the first as fallback
console.log('No healthy server found, using fallback')
return SERVERS[0]
}
// Main handler
export default {
async fetch(request, env, ctx) {
// Pick server
const server = await getAvailableServer()
// Build target URL keeping original path
const url = new URL(request.url)
const targetUrl = new URL(url.pathname + url.search, server.url)
// Clone request for the chosen server
const modifiedRequest = new Request(targetUrl, {
method: request.method,
headers: request.headers,
body: request.body,
redirect: 'follow'
})
// Debug header (optional)
modifiedRequest.headers.set('X-Served-By', server.name)
try {
// Proxy request
const response = await fetch(modifiedRequest)
// Clone response to add headers
const newResponse = new Response(response.body, response)
newResponse.headers.set('X-Served-By', server.name)
return newResponse
} catch (error) {
// If it fails, try the other server
console.log(`Error while accessing ${server.name}, trying fallback`)
const fallbackServer = SERVERS.find((s) => s.url !== server.url)
if (fallbackServer) {
const fallbackUrl = new URL(url.pathname + url.search, fallbackServer.url)
const fallbackRequest = new Request(fallbackUrl, {
method: request.method,
headers: request.headers,
body: request.body,
redirect: 'follow'
})
return fetch(fallbackRequest)
}
return new Response('All servers are unavailable', { status: 503 })
}
}
}
Setup (step-by-step)
-
Create two tunnel hostnames
- Primary tunnel hostname:
service-primary.yourdomain.com - Replica tunnel hostname:
service-replica.yourdomain.com
- Primary tunnel hostname:
-
Create a Worker
- Cloudflare Dashboard
- Workers & Pages
- Create Worker
- Paste the code and update the
SERVERSlist
-
Route your public hostname to the Worker
Add a route (or a custom domain) so
service.yourdomain.com/*is handled by this Worker. -
Test which server is serving requests
curl -I https://service.yourdomain.comLook for
X-Served-Byin the response headers. -
Test failover
Stop the primary tunnel temporarily and retry the same request. The header should switch to your replica.
Notes and limitations
- Stateful apps: if your service stores state on disk (sessions, uploads, chat history, etc.), you probably want shared storage or sync between primary and replica.
- Health check endpoint: prefer a lightweight endpoint (or
/) and avoid heavy logic. - WebSockets / long-lived connections: depending on your Cloudflare plan and your app’s behavior, long-lived connections may require extra care.
- Cache scope:
serverHealthis isolate memory, not a global datastore. If you need more reliable shared health state, consider KV, Durable Objects, or external monitoring.
Another way to do it
You could/could use the same Tunnel in two servers/services, so you could have a Tunnel named “Services” and connect it to “Server A” and “Server B”. Some people think this solution is better, and in terms of simplicity, it is better. But if you want to separate the Tunnels, Cloudflare Worker Load Balancer is better.
But if you still want a Tunnel in all your servers, here’s what I noticed about having 1 Tunnel in all machines: If you use a shared Tunnel in all machines:
┌─────────────────┐
│ Tunnel "main" │
└─────────────────┘
│
┌───────────┼───────────┬───────────┐
▼ ▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│Server A │ │Server B │ │Server C │ │Server D │
│ │ │ │ │ │ │ │
│Jellyfin │ │Navidrome│ │qBittor. │ │OpenWebUI│
└─────────┘ └─────────┘ └─────────┘ └─────────┘
Problem: Cloudflare will route jellyfin.domain.com to ANY server (A, B, C or D randomly), but Jellyfin is only on Server A! 💥 Result:
- 75% of the requests will result in error 502 (they fell on the wrong server)
- You lose control of where each service runs
- Total mess
Works but it’s not recommended.
In this case, THE CORRECT WAY to use replicas with a single Tunnel would be a Tunnel sharing ONLY when the SAME service is replicated on MULTIPLE machines:
Scenario 1: You want failover from Open WebUI
┌─────────────────────┐
│ Tunnel "openwebui" │
└─────────────────────┘
│
┌────┴────┐
▼ ▼
┌─────────┐ ┌─────────┐
│Server A │ │Server B │
│ │ │ │ ← Same Open WebUI (PostgreSQL+Redis shared)
│OpenWebUI│ │OpenWebUI│
└─────────┘ └─────────┘
Works, because Open WebUI is in the two servers.
Scenario 2: Jellyfin is only on 1 server
┌──────────────────┐
│ Tunnel "media" │
└──────────────────┘
│
▼
┌─────────┐
│Server A │
│ │
│Jellyfin │ ← Only here
└─────────┘
✅ Correct! Dedicated Tunnel because Jellyfin is only in 1 place.
Summary:
- 1 tunnel per machine: Different services in different machines
- 1 tunnel in multiple machines: Same service replicated (failover)
- 1 tunnel in all machines: Never (unless all services are in all machines)
Remember kindly
Don’t forget that you need shared data to be accessible by all servers, otherwise you’ll have inconsistencies in the data. And until configurations diverge, or having to configure each one differently.