One Hostname, Two Origins: Cloudflare Load Balancer Cutover — hero banner

One Hostname, Two Origins: Cloudflare Load Balancer Cutover

September 04, 2026·11 min read

We have a platform at work living on two sets of infrastructure at once. There is the original one, which everybody knows the name of and half the internal tooling has hardcoded. Then there is the new one, which got stood up beside it with the word prod wedged into its hostname because at the time nobody had a better idea. Nobody likes that name. The plan is that the original hostname survives, the new infrastructure ends up underneath it, and prod quietly disappears from anything a human ever types.

Most of the work here is deciding what a hostname is allowed to mean, and who gets to depend on it. The load balancer is just what lets me change the answer without changing the name. So before touching anything that matters, I built the whole shape on my own domain, where the blast radius is me.


The Goal

Three things:

  1. service.chrishouse.io is the only name anyone sees, permanently. Whatever serves it can change underneath without anybody noticing.
  2. Behind it, two independent origins, service-east and service-west, standing in for old infrastructure and new.
  3. Moving production from one to the other is one deliberate action, it takes seconds, and it reverses just as fast.

The thing I explicitly did not want is a percentage ramp. Ten percent, then fifty, then ninety sounds sophisticated, but it only means anything if both backends are genuinely interchangeable to a user: shared database, no diverging session state. If they are not, a 50/50 split is two different worlds served at random. What I wanted was a switch. This side is live now, with an undo button next to it.


The Building Blocks

Cloudflare Load Balancing is the steering layer. It runs about $5 a month at the entry tier with two origins included, which is exactly two origins more than I need to prove the point. The object model is small: a load balancer attached to a hostname, holding an ordered list of pools, each pool holding endpoints, with a monitor health-checking them.

Cloudflare Tunnel connects the origins. Both of mine are containers on the desktop under my desk. No port forwarding, no static IP, no inbound anything. Cloudflare's own guidance is one tunnel per data centre and one pool per tunnel, which maps onto east and west without requiring any creativity.

Two nginx containers are the origins, standing in for the two application stacks. They do no routing and no load balancing of their own. All of that happens at the edge, and these are just the things at the far end that answer. Identical config, different environment variables, so the only honest difference between them is the label each one prints about itself. If the two sides were not interchangeable, the demo would be lying.

x-origin: &origin
  image: nginx:1.27-alpine
  restart: unless-stopped
  volumes:
    - ./nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro

services:
  east:
    <<: *origin
    environment:
      REGION: east
      STACK: legacy
      RELEASE: v1
    ports: ["8081:80"]

  west:
    <<: *origin
    environment:
      REGION: west
      STACK: new
      RELEASE: v2
    ports: ["8082:80"]

There is no application behind either one. They exist to answer a request and admit which side they are, and that admission is four lines of nginx config. This is a stub, not something you would ever put in a real config:

location = /api/whoami {
    default_type application/json;
    return 200 '{"region":"${REGION}","stack":"${STACK}","hostSeen":"$host"}';
}

${REGION} and ${STACK} get substituted once at container start by the nginx image's envsubst entrypoint, so they say what the container is. $host is an nginx runtime variable evaluated per request, so it says what the container was asked for. Those two being able to disagree is the entire point.


The Architecture

                  service.chrishouse.io
                           |
                  Cloudflare Load Balancer
                  steering: off (failover order)
                           |
              +------------+------------+
              v                         v
        pool: east                pool: west
        (live)                    (standby)
              |                         |
     <uuid>.cfargotunnel.com   <uuid>.cfargotunnel.com
              |                         |
        cloudflared               cloudflared
              |                         |
        nginx :80                 nginx :80
        legacy / v1               new / v2

The one setting that makes this work is traffic steering set to Off. Cloudflare offers dynamic, geo, proximity and least-outstanding-requests steering, and every one of them hands the routing decision to a latency measurement. Off does something better. It uses the pool list in order, and the first healthy pool takes everything. The order of that list is the switch.


The Host Header

Each endpoint in a pool carries a host header, and what you put there decides the entire character of the migration.

Set it to the origin's own name and you get override mode. The visitor types service.chrishouse.io, the backend receives service-east.chrishouse.io. It works, and it is the easy option, because the backend needs to know nothing.

The trouble is what a backend does with the hostname it thinks it has. A 302, a cookie Domain attribute, an OIDC redirect_uri, a link in a notification email. All of them get built from the name the app believes it is serving. In override mode an app will happily hand your users the internal hostname, which is the name you were trying to retire in the first place.

Preserve mode is the other option. Put the public name in the host header on every pool, and the backend sees service.chrishouse.io no matter which side is serving. It never learns it moved.

That costs something. Every backend has to actually answer to the final hostname before you can point the final hostname at it, and how much work that is depends on how the backend is reached.

A backend exposed as a public HTTPS origin needs two things per app: a routing rule for the hostname, and that hostname on the certificate. On Kubernetes that is a host: rule on the Ingress plus the name on the cert-manager SAN list, or hostnames: on the HTTPRoute if you are on Gateway API, where the parent Gateway's listener also has to permit it.

A backend behind a Cloudflare tunnel only needs the routing half. Cloudflare terminates TLS at the edge, the tunnel is encrypted, and cloudflared talks plain HTTP to the service inside the cluster, so there is no public certificate to issue for that hostname at all. Cert-manager drops out of the critical path entirely, which is a smaller change than it first looks like.

What you do need is a rule on the tunnel:

service.chrishouse.io       -> http://east:80
service-east.chrishouse.io  -> http://east:80
(any host)                  -> http_status:404

Worth knowing: the Zero Trust dashboard will not let you add a rule without a hostname, because in that UI a route is a published hostname and it creates a DNS record to match. The catch-all slot is already occupied by the terminating http_status:404 that cloudflared requires. Extra rules go in through the API. I would use the API anyway. Naming the hostname explicitly is the move you will make on every real app, so the rehearsal may as well practise it rather than take a wildcard shortcut.

The proof is one field. Here is the origin describing itself, through the load balancer, in preserve mode:

{
  "region": "west",
  "stack": "new",
  "release": "v2",
  "hostSeen": "service.chrishouse.io"
}

hostSeen is $host straight out of nginx, whatever the container was actually asked for. When that reads as the public name, the abstraction is real. When it reads as the origin's own name, you have a leak waiting to surface in somebody's password reset email.


The Cutover

With steering off, promoting a side means reordering a list. Having that as a script rather than a click path is what makes it something other people can run:

await cf("PATCH", `/zones/${zone.id}/load_balancers/${lb.id}`, {
  default_pools: [idOf[target], idOf[standby]],
  fallback_pool: idOf[target],
})
$ node cutover.js west
cut over: east -> west   (fallback now west)

$ node cutover.js
live:     west
order:    west -> east
fallback: west

Under eight seconds from command to the new side serving, and node cutover.js east puts it back just as quickly. No DNS record changes, so there is no TTL to wait out and no client cache holding a stale answer. The decision happens at Cloudflare's edge on every request, which is the entire argument for a load balancer over swapping a CNAME.

Note the fallback pool moving along with the primary. The fallback is where traffic goes when everything reads unhealthy, and by far the likeliest cause of that is a monitor you misconfigured, not two independently dead stacks. It should always point at whichever side you currently trust most, which means it changes when the primary changes. Doing that by hand is a step people forget until an incident reminds them.

A script in a runbook still assumes somebody finds the runbook, holds an API token, and types the right argument at 2am. The version I actually want is this wrapped as a Day-2 action in Port, sitting on the service entity next to everything else you can do to it. The input is a dropdown with two values. The backend runs the same cutover.js from a workflow, so what the portal triggers is the same thing I would run by hand. What the portal adds is not the logic, it is the guardrails: who is allowed to press it, and what gets written down when they do. Moving production between two sets of infrastructure is exactly the kind of operation that should leave a record of who did it and when, and a terminal on my laptop leaves none.


When the Hostname Already Exists

The demo had it easy. I invented service.chrishouse.io and made it a load balancer from nothing. The real migration does not get that. The hostname already exists, already has a DNS record, and already has users on it right now. A Cloudflare load balancer owns a hostname, so the record and the load balancer cannot both have it.

The way through is to make the takeover a no-op. Point the load balancer's first pool at exactly what the DNS record already points at. Same origin, same host header, same response. The hostname changes what kind of object it is, and nothing else about the request path moves at all. Nothing has migrated yet. All you have changed is who decides where traffic goes, which is the thing you need in place before you can migrate anything.

How risky that moment is comes down to one question: is the hostname proxied today?

If it is already orange-clouded, this is close to free. Clients resolve to Cloudflare anycast addresses now and will resolve to Cloudflare anycast addresses afterwards. Nothing a client can observe changes, and there is no TTL to wait out. If it is grey-clouded and pointing straight at an ingress IP, clients would move from your address to Cloudflare's, which is full propagation exposure with a slow rollback. That case deserves its own change on its own timeline: lower the TTL, orange-cloud it, let it settle for a week, and only then treat the load balancer as a separate step.

The sequence:

  1. Save exactly what the record is today. Type, content, proxied flag, TTL. That is your rollback and there is no other copy of it.
  2. Get an origin address for legacy. If the record is a CNAME, its target already works. If it is an A record, the IP does. A DNS-only alias like myapp-legacy keeps the pool config readable and lets you repoint legacy later without touching the pool.
  3. Build the pool in preserve mode from the start, with the public hostname in the host header. This is the good part. Legacy already answers to that name and its certificate already covers it, so legacy needs no changes whatsoever. Every bit of work lands on the new cluster.
  4. Create the pool and monitor with no load balancer attached, and confirm the pool reads healthy. Pools are account-level objects, so you can validate the health check before anything is load-bearing.
  5. Rehearse on a parallel hostname. Pools are reusable across load balancers, so a throwaway name gives you a full dress rehearsal against the real origins.
  6. Create the real load balancer with only the legacy pool in it.
  7. Leave it alone for days. Nothing has migrated.
  8. Then put the public hostname on the new cluster's routing rules, and on its certificate too if it is reached as a public origin rather than through a tunnel. Add it as a second pool on standby, and reorder when you are ready.

Steps 1 through 5 change nothing. Step 8 reverses in seconds. Step 6 is the only one-way door, and only in the sense that undoing it means recreating a DNS record by hand instead of reordering a list. That is what step 1 is for.


Design Decisions

Why failover order instead of weights? Because a weighted ramp is a promise about equivalence I could not honestly make for the real migration. Two backends only blend if they share state. Failover order gives me one live side and one standby, which is what I actually wanted.

Why tunnels instead of public origins? Both origins are containers on a desktop in my house. A tunnel means no open ports, no static IP, and an origin address that only Cloudflare can route to. It also matters that the endpoint has to be the <uuid>.cfargotunnel.com hostname directly. A friendlier CNAME pointing at it is explicitly unsupported, which is a fun twenty minutes if you assume otherwise.

Why one monitor for both pools? A monitor defines how to check. The address and host header come from each endpoint, so one monitor means both sides are judged by an identical standard, which is the only way a comparison between them means anything. It also halves the health-check volume you are billed for.

Why adaptive routing on? Failover across pools lets a request move to a healthy pool immediately instead of waiting for the monitor to notice. With a 60 second check interval, that is the difference between a minute of errors and none.

Why not just change DNS? Because rollback is the whole point. A DNS change propagates at the mercy of every resolver and browser between you and your users, so the undo button takes as long as the button did. Edge-side steering reverses in seconds, and that is what lets you attempt a cutover during business hours instead of at midnight.

Why not put the routing in nginx? spydergsx, a former colleague I still use as a sounding board, put this better than the rest of the post does: "you don't want routing baked into a config file, or your rollback becomes a config edit and reload instead of an 8-second API call." Rolling back at the edge is the same single API call the change was. Rolling back a config file is an edit, a commit, a pipeline and a reload, on every box holding a copy of it.


Two Things Worth Knowing Before You Try It

Failover is not cutover. In active-passive, when the primary pool recovers, traffic automatically returns to it. I watched this happen live. East went unhealthy, west took over, east recovered, and traffic moved back on its own without me touching anything. Which means if you migrate by making the old side unhealthy, you have not migrated. You have taken a temporary detour that ends the moment somebody fixes the old health check. Reordering the pools is the only durable move.

Change one side at a time. The host header is used by the health monitor too, so changing it on both pools at once takes both pools unhealthy simultaneously, and failover has nowhere to go when the failure is correlated. Adaptive routing, the fallback pool, none of it saves you. Change the standby side, verify it, then cut over. Do it in that order and the safety mechanisms you are paying for can actually engage.


Conclusion

The shape is small enough to hold in your head. One permanent public name at the edge, an ordered list of pools underneath it, and backends that answer to the public name rather than to their own. Cutting over is a list reorder. Rolling back is the same reorder backwards. The internal hostname never reaches a user's address bar or their bookmarks.

The rehearsal cost $5 and an evening, and it has already changed the real plan three times over. The new infrastructure has to answer to the final hostname before anything gets pointed at it. The two sides get migrated one at a time. And the load balancer goes live with only the legacy pool in it, so the day the hostname changes hands is a day when nothing else happens. All three are obvious in hindsight and none of them were in the original plan.

If you have a migration coming where a hostname needs to outlive the infrastructure under it, build the two-origin version first on a domain nobody depends on. The failure modes are identical and considerably cheaper.

Enjoyed this post? Give it a clap!

Comments