So you’re about to stand up a fleet of OpenShift clusters. Hub clusters for preproduction and production. A services cluster for your registry and pipelines. Workload clusters for dev, test and prod. Before you provision a single node, you have to answer a question that feels like a bikeshed but absolutely is not: what do you name everything?
I say it’s not a bikeshed because cluster names don’t stay put in one tidy place. They get baked into DNS, into TLS certificate SANs, into kubeconfig server URLs, into your ACM ManagedCluster objects, and into your ArgoCD cluster secrets. Once your workloads and pipelines start referencing those names, changing them is a genuinely miserable afternoon. The name you pick on day one is a name you’re going to live with for a long time.
This post is about naming a multi-cluster fleet, but really it’s about one decision that matters more than all the rest: whether your environment is a cluster name or a cluster label. I’ll also walk through a tempting shortcut — CNAME-ing an environment name onto a physical cluster — and why it doesn’t do what you want. It’ll make sense in a minute.
The Topology
Let’s assume a base domain of cluster.example.com and a fleet that looks like this.
cluster.example.com (base domain — OpenShift infra)
│
├── svc.cluster.example.com services: Quay + OpenShift Pipelines
│ └── images.example.com → registry vanity domain
|
├── mgmt.cluster.example.com management + hosting: ACM, GitOps, Hosted Control Planes
│ └── hosts the control-plane pods for the workload clusters
│
├── dev.cluster.example.com hosted workload cluster
├── test.cluster.example.com hosted workload cluster
└── prod.cluster.example.com hosted workload cluster
A couple of principles are already baked into this picture, and they’re worth saying out loud because they drive everything that follows.
First, things depend on the registry, so the registry lives low in the stack. Your workload clusters pull images from it. Your pipelines push to it. If recovering a cluster required pulling images from a registry that itself runs on a cluster that’s down, congratulations, you’ve built a circular dependency. So the registry lives on a stable services cluster — not on the hub, and not squeezed in next to anything bursty.
Second, CI is data-plane load and it does not belong on the management plane. Builds are hungry, bursty, and they run semi-trusted code out of pull requests. Your hub is the control plane for your whole fleet. Put pipelines on the hub and a runaway build is now sharing a node with the thing that manages dev, test, and prod. Pipelines go on the services cluster with the registry.
Third — and this one is easy to underestimate — with hosted control planes, the hub is carrying real weight. Your workload clusters don’t run their own control-plane nodes. Their API servers, their etcd, their controllers all run as pods on the management cluster. That’s a fantastic efficiency win, but it means the hub is now etcd- and latency-sensitive, and it hates noisy neighbors. Which is one more reason your registry and your pipelines live somewhere else.
Naming the Infrastructure
For the clusters, keep it boring. Each cluster gets a base domain of <name>.cluster.example.com, with the API at api.<name>.cluster.example.com and ingress at apps.<name>.cluster.example.com. That’s just the default OpenShift shape with the cluster name plugged in.
The management cluster is mgmt, and it’s where ACM, GitOps, and the hosted control-plane namespaces (clusters-dev, clusters-test, clusters-prod) all live. The services cluster is svc, and it hosts Quay and your pipelines.
Now, the registry gets something the clusters don’t: a vanity domain that has nothing to do with the cluster’s apps domain.
Registry: images.example.com → route on apps.svc.cluster.example.com
Nobody should be typing quay-quay.apps.svc.cluster.example.com into a pull spec. You set Quay’s SERVER_HOSTNAME to a clean name and point a route at it. The whole value of images.example.com is that it never changes, even if you tear down and rebuild the services cluster underneath it. Hold onto that idea — the registry is a hint about a pattern we’re going to lean on hard in a second: vanity names belong at the service layer, where they’re safe to move.
The decision that actually matters
Here’s where a lot of naming schemes quietly plant a landmine. It is very tempting to name your workload clusters dev, test, and prod. It reads beautifully. prod.cluster.example.com — you know exactly what that is.
The trouble is that environment isn’t an identity. It’s a policy dimension. And the mapping is almost never one-to-one for long. You end up with two prod clusters for HA or across regions. Or a dev cluster that’s also hosting test namespaces during a crunch. Or you’re migrating prod, and the replacement cluster is “the new prod,” and now you’re staring at prod and prod2 wondering whether prod2 is somehow less production than prod.
Every one of those gets awkward the moment the cluster name is the environment. So here’s the convention that ages well: the cluster name describes the physical cluster, and the environment is a label applied in ACM and consumed by GitOps.
apiVersion: cluster.open-cluster-management.io/v1
kind: ManagedCluster
metadata:
name: aws-us-east-2-001 # physical identity — matches HostedCluster, cert SANs, kubeconfig
labels:
environment: dev # ← the "dev" pointer. this is the thing that moves.
tier: workload
region: us-east-2
provider: aws
spec:
hubAcceptsClient: true
The cluster is aws-us-east-2-001 everywhere it counts — in its HostedCluster object, in its certificate SANs, in its kubeconfig, in its ACM registration. And “dev” is just a label hanging off of it. Nothing downstream cares about the physical name. Everything targets environment: dev.
I’ll be fair here: if you’ve got exactly three workload clusters and you are dead certain that number isn’t going to change, then naming them dev/test/prod is more readable and perfectly fine. The label scheme is insurance against growth. But if this is a platform other teams are going to onboard onto, do the label thing now, because renaming clusters after workloads reference them is the afternoon I warned you about.
The CNAME trap
At this point a very reasonable idea shows up. Why not have both? Keep the physical name, and just CNAME a friendly environment name onto it. Make dev.cluster.example.com an alias for aws-us-east-2-001.cluster.example.com and get the best of both worlds.
You can absolutely create that CNAME. It’ll resolve. But it does not buy you the thing you actually want, and understanding why is what makes the whole design click.
A cluster is not just a DNS record. Its identity is welded into a few layers that a CNAME simply cannot alias.
The certificates, for one. The hosted API server’s cert carries SANs derived from the cluster’s real base domain — api.aws-us-east-2-001.... Hit it through api.dev... and the TLS handshake blows up on a SAN mismatch. The name resolves; the connection doesn’t. Unless you also go add dev to the cert, which is exactly the kind of hand-maintenance you were trying to avoid.
The object identity, for another. ManagedCluster/aws-us-east-2-001 is an object in etcd, not a DNS record. ACM, your ArgoCD cluster secrets, and your oc login context all key off the real name. Aliasing a hostname doesn’t make any of that answer to a second name.
And the derived endpoints — the kubeconfig server URL, the ingress apps domain, the records external-dns is managing — are all built from the real base domain. None of them follow your vanity CNAME.
So a cluster-level CNAME hands you a second name that resolves while every layer above DNS still knows the cluster by its real name. You’re maintaining a cert SAN and a mental mapping for an alias that only half-works. That’s worse than either naming scheme on its own.
But here’s the thing — the instinct behind the CNAME is correct. You want a stable, friendly pointer (dev) that’s decoupled from the physical cluster (aws-us-east-2-001) so you can repoint it later. That is exactly the right goal. The fix is just that the indirection belongs in the label and GitOps layer, not in DNS. Because a label drags the cert, the kubeconfig, and the object identity along with it. A CNAME can’t.
Making the label do the work
A label is only worth anything if something acts on it. In an OpenShift GitOps stack, that something is an ArgoCD ApplicationSet fed by an ACM Placement. ACM publishes cluster membership — labels and all — to ArgoCD, and the ApplicationSet’s cluster generator selects on those labels.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: myapp
namespace: openshift-gitops
spec:
goTemplate: true
generators:
- clusters:
selector:
matchLabels:
tier: workload
template:
metadata:
name: 'myapp-{{.metadata.labels.environment}}' # myapp-dev, myapp-test, myapp-prod
spec:
project: default
source:
repoURL: https://github.com/<YOUR_ORGANIZATION>/myapp-config.git
targetRevision: main
path: 'overlays/{{.metadata.labels.environment}}' # config picked by the label
destination:
server: '{{.server}}' # the cluster wearing that env label
namespace: myapp
syncPolicy:
automated:
prune: true
selfHeal: true
That gives you three ArgoCD Applications — myapp-dev, myapp-test, myapp-prod — each pointed at whichever cluster currently wears that environment label, and each pulling its own overlays/<env> config. The image reference inside those overlays is a digest-pinned pull like images.example.com/prod/myapp@sha256:... — built once, promoted by digest, never rebuilt per environment.
Letting the app’s DNS ride along
So the deployment follows the label. But what about the URL people actually hit? This is where the vanity-name idea from earlier comes all the way home. Remember the registry: images.example.com is a stable name at the service layer, safe to repoint because it isn’t welded to any cluster’s identity. Your application URLs are exactly the same kind of thing, and they should move with the label too.
Say myapp is reachable at these three names:
myapp.dev.example.com → dev
myapp.test.example.com → test
myapp.example.com → prod (the bare, production name)
You do not want to hand-maintain those DNS records. You want them derived from where the app is actually running, so that when the label moves, the record follows. That’s what external-dns is for. Drop the hostname on the Route as an annotation in each env overlay and let external-dns reconcile the record from it.
# overlays/prod — the Route for myapp
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: myapp
namespace: myapp
annotations:
external-dns.alpha.kubernetes.io/hostname: myapp.example.com
spec:
to:
kind: Service
name: myapp
tls:
termination: edge
The dev and test overlays carry the same Route with myapp.dev.example.com and myapp.test.example.com respectively. Since these live in the per-environment overlays, they’re already selected by the environment label — the exact same mechanism that placed the workload. external-dns running on each cluster sees the Route, and publishes a record pointing myapp.<env>.example.com at that cluster’s ingress.
Here’s the nice part. The record is built from the Route on whatever cluster the app landed on, and the app lands on whatever cluster wears the label. So the DNS name is now transitively bound to the label, not to a cluster. Move environment=prod to a new cluster and three things happen without you touching DNS: ArgoCD deploys the prod overlay there, the Route comes up on the new cluster, and external-dns repoints myapp.example.com at the new ingress. The bare production name follows production wherever production goes.
Where your existing DNS fits in
Now, a fair question: you almost certainly already have DNS. There’s a corporate DNS team, or an IPAM appliance, or a cloud zone that somebody guards carefully, and they are not about to hand a Kubernetes controller write access to example.com. Good. They shouldn’t. external-dns doesn’t replace your existing DNS — it plugs into a delegated slice of it, and the seam is a plain old zone delegation.
The pattern that keeps everyone happy is to carve off a subdomain that external-dns owns completely, and delegate just that. Your corporate DNS stays authoritative for example.com and keeps hand-managing the records it’s always managed. Then it delegates a zone — say cluster.example.com — down to the DNS provider that external-dns writes to (a Route 53 hosted zone, a RFC 2136 dynamic zone on your BIND/Infoblox, whatever your provider integration supports). A handful of NS records in the parent zone, pointing cluster.example.com at the delegated zone’s nameservers, and you’re done. That’s the entire integration.
example.com ← corporate DNS, authoritative, hand-managed
│
├── (all your normal corporate records, untouched)
│
└── cluster.example.com ← NS delegation to the external-dns-managed zone
│ external-dns has write access ONLY here
├── api.mgmt.cluster.example.com
├── apps.dev.cluster.example.com
└── ... every cluster + app record reconciled automatically
The blast radius is the whole reason to do it this way. external-dns holds credentials to exactly one delegated zone and can’t touch anything in the parent. Your DNS team reviews one delegation once, instead of reviewing every app hostname forever. And everything under cluster.example.com becomes reconciled output rather than tickets in a queue — which is the same philosophy as the rest of this setup, just applied to DNS.
That does leave one wrinkle worth calling out: the bare production name, myapp.example.com, lives up in the parent zone, not the delegated one. You’ve got two honest options. Either let external-dns manage a second, narrower delegated zone for your public product names, or — if the corporate team wants to keep the apex tidy — leave myapp.example.com as a CNAME in corporate DNS pointing at a stable, external-dns-managed name like myapp.prod.cluster.example.com. That CNAME is a service-layer alias, exactly the kind we said works great, so it’s a clean hand-off rather than a contradiction. The delegated name does the label-following; the corporate CNAME just points at it and never has to change.
That’s the whole point of keeping vanity names at the service layer. myapp.example.com and images.example.com are stable public contracts. The clusters underneath them are cattle. The label is the seam that lets you swap the cattle without ever renegotiating the contract.
A word on TTL, and when to reach for a load balancer
One thing to keep honest about this whole label-follows-DNS story: DNS is only as fast as its TTL. When you move environment=prod and external-dns rewrites the record, resolvers and clients that already cached the old answer will keep hitting the old cluster until that cached entry expires. If your records carry a lazy one-hour TTL, your “instant” cutover has an hour-long tail. So set the TTL deliberately on the records external-dns manages — the external-dns.alpha.kubernetes.io/ttl annotation on the Route lets you do it per-record — and keep it short, on the order of 30 to 60 seconds, for names you actually expect to move. Short TTLs mean more resolver traffic, but for a handful of app hostnames that cost is nothing, and it’s the difference between a cutover that lands in under a minute and one that drags.
If even a short TTL is too much — say for a bare production name where you want a true zero-tail, mid-request cutover — that’s the signal to stop moving the DNS record and start moving traffic behind a stable one. Put a load balancer (or a global/GSLB tier, or an edge like an F5 or a cloud LB) in front, give it the one stable address that myapp.example.com resolves to, and never move that record at all. Now the cutover is a change to the LB’s backend pool — drain the old cluster’s ingress, add the new one — which takes effect in seconds and doesn’t depend on anyone’s resolver cache. The label still does the orchestration underneath: external-dns can publish the per-cluster ingress names (myapp.prod.cluster.example.com on whichever cluster wears the label) and your LB health-checks or points at those. You’ve just moved the fast-switching seam off DNS and onto the load balancer, and left DNS to do the slow, stable job it’s good at.
The obvious next question is who actually reconfigures that load balancer when the label moves — because if the answer is “a human opens a ticket,” you’ve reintroduced the manual step you spent this whole post designing away. Better to let the same label change drive the LB update. The trick is that the label swap is an observable event on the hub: a ManagedCluster gained or lost environment=prod. Anything that can watch that event can react to it. A small controller or operator watching ManagedCluster objects can, on a label change, resolve which cluster now owns prod and push the new backend into the load balancer through its API — most edge and cloud LBs (F5, the cloud providers, MetalLB-fronted setups) expose one, and something like external-dns’s cousin projects or a purpose-built Kustomize/Ansible hook can do the talking. If you’d rather not write a controller, keep it in the GitOps lane: the label already selects overlays, so let an ApplicationSet render the LB’s backend config as just another managed resource — the pool membership becomes a value templated from {{.metadata.labels.environment}}, committed and reconciled like everything else. Either way the principle holds: the label is the single source of truth, and the load balancer is one more subscriber reacting to it, not a separate thing you remember to go touch.
The payoff
Now for the part that makes all of this worth it. Say you stand up aws-us-east-2-004 to replace the tired old dev cluster. Moving “dev” to the new box is two commands.
oc label managedcluster aws-us-east-2-001 environment-
oc label managedcluster aws-us-east-2-004 environment=dev tier=workload
On the next reconcile, myapp-dev’s destination resolves to the new cluster’s API, ArgoCD lays the dev overlay down on it, and you’re done. No DNS change. No cert reissue. No kubeconfig edit. No renaming anything.
Compare that to the CNAME world, where “moving dev” would have meant repointing DNS and reissuing the API cert with a new SAN and editing the kubeconfig server URL — and the ManagedCluster object still wouldn’t have moved. The label moves every layer at once precisely because nothing was ever aliased. The indirection lived in the selector, not in DNS.
The same trick gives you blue/green cluster replacement for prod. Bring up a candidate labeled environment=prod-candidate, validate the app running on it, then swap the labels to cut over. Your user-facing vanity domain — myapp.example.com, driven by external-dns following the label — comes right along. That atomic, all-layers repoint is the thing a cluster-level CNAME fundamentally cannot give you.
The whole thing in one line
If you take away nothing else, take this. The physical name is identity — immutable, and consistent everywhere, certs and kubeconfigs included. The environment label is the pointer — movable, and the thing GitOps acts on. And vanity DNS lives at the app and service layer only, where external-dns can follow the label and repointing is actually safe.
The CNAME you were reaching for isn’t wrong. It’s just at the wrong layer. Down at the service layer it works great. Up at the cluster layer, the label is your CNAME — and it’s a much better one, because it brings the whole cluster along with it.