The deprecation notice that had been sitting in Cluster API's release notes without a date finally has one: April 2027. The CAPI version-support page now reads "Deprecated since CAPI v1.11; in v1.16, April 2027 v1beta1 will stop to be served," and the v1.14 release notes repeat it in the imperative — the v1beta1 API version in core Cluster API, CABPK and KCP "is on track to be unserved in CAPI v1.16," and "all the consumers of this API version should migrate to v1beta2 ASAP."
Twenty months sounds like a lot. It isn't, for two reasons. The first is that a nearer deadline lands first: CAPD's Docker* resources are removed in v1.15, not deprecated — and if your CI provisions throwaway clusters with DockerCluster/DockerMachine, that breaks a full release before the v1beta1 flip does. The second is that "unserved" is a categorically harder failure than "deprecated," and the work it demands is an audit, not an upgrade. You cannot schedule the fix until you know how many places in your platform still say cluster.x-k8s.io/v1beta1 — and for a platform that provisions tenant clusters, that string hides in at least seven of them.
Here is the audit, the commands to run it, an honest accounting of how much of it the new clusterctl convert does for you, and how to sequence the upgrade when your management cluster's supported Kubernetes window is narrowing at the same time.
"Unserved" is not "deprecated" — it fails at the API server, not in a warning
A deprecated API version still answers. You get a warning header, your kubectl apply succeeds, and the warning scrolls past in CI. An unserved version does not answer at all.
Every version block in a CRD carries two independent booleans: served controls whether clients can read or write that version through the API, and exactly one version is the storage version that actually gets written to etcd. Flipping served: false on v1beta1 doesn't delete anything. Objects stay in etcd, the conversion machinery keeps round-tripping them, and CAPI's own controllers — which have read and written v1beta2 internally since v1.11 — carry on reconciling.
That distinction decides who feels this and when:
What keeps working after the flip. Your existing tenant Cluster, MachineDeployment, and KubeadmControlPlane objects keep reconciling. Machines don't roll. Tenant workloads don't notice. The stored objects were converted to the v1beta2 storage version long before v1.16, and CAPI deliberately keeps v1beta1 present but unserved from v1.14 through v1.17 precisely so that managedFields cleanup runs even for someone jumping from n-3 to n.
What hard-fails. Anything that names the version. A kubectl apply -f on a manifest whose apiVersion says cluster.x-k8s.io/v1beta1 returns an error, not a warning. A Helm chart that templates that string fails at install. A GitOps reconcile marks the whole kustomization failed and stops — which, if you use Flux or Argo as the delivery path for tenant clusters, means a single stale manifest halts unrelated changes behind it. A typed Go client built against the v1beta1 package gets a 404 from the discovery endpoint. An RBAC rule scoped to that group/version silently grants nothing.
The asymmetry is the whole problem. Nothing breaks at upgrade time in a way that shows up in a smoke test — it breaks the next time somebody creates or edits a cluster.
The audit: seven places v1beta1 hides in a CAPI platform
Run all seven. Each returns a list; the migration is done when all seven return empty.
| # | Where it hides | How to find it |
|---|---|---|
| 1 | Manifests, charts, GitOps repos | grep -rn 'x-k8s.io/v1beta1' --include='*.yaml' --include='*.yml' --include='*.tpl' . |
| 2 | What the API server still stores | kubectl get crd -o json | jq -r '.items[] | select(.spec.group|test("x-k8s.io")) | "\(.metadata.name) \(.status.storedVersions)"' |
| 3 | RBAC granting on the old group | kubectl get clusterrole,role -A -o json | jq -r '.items[] | select(.rules[]?.apiGroups[]? | test("x-k8s.io")) | .metadata.name' |
| 4 | ClusterClass patches keyed on apiVersion | kubectl get clusterclass -A -o yaml | grep -n -B3 'v1beta1' |
| 5 | Your own Go controllers and webhooks | grep -rn 'cluster-api/api/v1beta1|api/v1beta1"' --include='*.go' . |
| 6 | Dynamic clients / watches built from a GVK string | grep -rn 'GroupVersion{.*v1beta1|/v1beta1"' --include='*.go' --include='*.py' --include='*.ts' . |
| 7 | Your infrastructure provider's contract | clusterctl describe cluster <name> + check the provider's release line |
Rows 1, 5, and 6 are the ones teams find. Rows 3, 4, and 7 are the ones that bite.
Row 3 (RBAC) is nasty because a rule naming a group that no longer serves the requested version doesn't error — it just fails to match, and the controller or human it was written for gets a permission denial that reads like a misconfiguration rather than a version problem. Note that RBAC apiGroups are version-less, so a rule on cluster.x-k8s.io is fine; the ones to find are rules generated by tooling that encoded a version, and any resources entry naming a Docker* kind that v1.15 removes.
Row 4 (ClusterClass patches) is the single highest-risk row for a platform that offers tenants a templated cluster flavor. JSON patches in a ClusterClass select on paths inside the templated object, and inline patches frequently reference apiVersion explicitly. As we'll see, this is also the row the migration tool explicitly refuses to handle.
Row 7 (the provider contract) is a separate deadline that happens to share a release. Support for the v1beta1 contract — the interface your infrastructure provider implements, distinct from the API version your manifests name — is also on track to be dropped in v1.16. For Cluster API Provider Hetzner, the v1.1.x line implements the older v1beta1 contract and rides CAPI's temporary compatibility layer, while the v1.2.x line is the v1beta2 contract generation aligned with CAPI v1.11+. You can run a v1beta2 core with a v1beta1-contract provider today. You cannot in v1.16.
What clusterctl convert actually covers — and the four rows it doesn't
CAPI v1.14 ships an experimental clusterctl convert that reads a file or stdin, calls the conversion functions for every object it finds, and prints the result to stdout. It is genuinely useful and it is genuinely not the whole answer. The release notes list its limitations plainly; mapped against the audit above:
| Audit row | clusterctl convert | Why |
|---|---|---|
| 1. Manifests | Partial | Core CAPI resources only — your provider's HetznerCluster, HCloudMachineTemplate, etc. are untouched |
| 2. Stored versions | No | Not a manifest problem; needs storage-version migration on the live cluster |
| 3. RBAC | No | Not a converted type |
| 4. ClusterClass patches | Explicitly no | Documented as not migrated |
| 5. Go imports | No | Source code, not YAML |
| 6. Dynamic clients | No | Source code, not YAML |
| 7. Provider contract | No | A provider upgrade, not a conversion |
Two more things to know before you pipe your whole repo through it. The conversion is mechanical — it calls top-level conversion functions with no additional context, so anything requiring judgment comes out wrong or empty. And the output is re-serialized with a standard YAML library, which loses your comments and reorders fields. On a hand-maintained GitOps repo that's a review nightmare disguised as a diff. Convert into a scratch file, diff semantically, and port the changes by hand for anything a human maintains.
Also worth flagging: apiVersion on object references is dropped in most cases (with narrow exceptions like external remediation and ClusterClass refs), which is correct — but only because of a structural change you need to understand.
The field renames you'll actually hit
This is not the exhaustive list — it's the subset that shows up in essentially every topology-based platform's manifests. The full set is in the provider migration reference; budget time to read it rather than assuming these four are all of it.
# v1beta1
apiVersion: cluster.x-k8s.io/v1beta1
kind: Cluster
spec:
topology:
class: tenant-hetzner
classNamespace: platform
infrastructureRef:
apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
kind: HetznerCluster
name: tenant-a
namespace: tenant-a # dropped in v1beta2
status:
infrastructureReady: true# v1beta2
apiVersion: cluster.x-k8s.io/v1beta2
kind: Cluster
spec:
topology:
classRef:
name: tenant-hetzner
namespace: platform
infrastructureRef:
apiGroup: infrastructure.cluster.x-k8s.io # was apiVersion
kind: HetznerCluster
name: tenant-a
status:
initialization:
infrastructureProvisioned: trueReferences became ContractVersionedObjectReference: namespace, uid, resourceVersion, and fieldPath are gone, and apiVersion is replaced by apiGroup — the version is resolved from the provider's contract instead of pinned in your YAML, which is exactly why this migration is the last one of its kind. Separately, every duration field grew a Seconds suffix and became an int32 (nodeDrainTimeout → deletion.nodeDrainTimeoutSeconds), and the boolean status flags moved under a structured status.initialization.
That last one matters beyond YAML: if you have dashboards, alerts, or an internal API that reads status.infrastructureReady or status.controlPlaneReady off a Cluster, those paths are now status.initialization.infrastructureProvisioned and status.initialization.controlPlaneInitialized. Grepping only for apiVersion will not find them.
Prove it: the check that gives the audit an exit condition
An audit that finds references has no natural stopping point. Give it one — rehearse the failure before April 2027 does it for you.
- Ask the API server directly what it still serves:
kubectl get --raw /apis/cluster.x-k8s.io/v1beta1 | jq -r '.resources[].name'This is your before-picture and, later, your proof. - Flip
served: falseon a staging management cluster. Edit thev1beta1entry in the relevant CRDs'spec.versionson a non-production management cluster and leave it that way for a sprint. Every stale reference in your platform surfaces as a real error, in your own environment, on your own schedule. This is the single highest-value step in the whole migration and it costs one afternoon. - Confirm storage is clean before you consider the flip permanent:
status.storedVersionson each CAPI CRD should list onlyv1beta2. Ifv1beta1is still there, run a storage-version migration to rewrite the remaining objects — otherwise reads depend on conversion indefinitely. - Re-run the seven-row audit. All empty, staging green with
served: falsefor a full release cycle,storedVersionsclean. That's done.
Sequencing: two axes, not one timeline
The trap in this migration is treating it as a date on a calendar. It's a compatibility matrix with two axes — your CAPI version and your management cluster's Kubernetes version — and they constrain each other.
The ordering rule, from the CAPI book: upgrade clusterctl first, then use clusterctl upgrade apply to move the providers, then upgrade Kubernetes. The reason is directional — the management cluster's CAPI version determines which workload Kubernetes versions it can provision, so CAPI has to lead. Management and workload cluster Kubernetes versions are allowed to differ and can be upgraded independently, which is the flexibility that makes this survivable.
The constraint that surprises people: CAPI v1.14 supports management clusters on Kubernetes v1.33.x–v1.36.x (workload clusters v1.31.x–v1.36.x). If your management cluster is sitting below v1.33 — plenty are, because management clusters are boring and boring things don't get upgraded — you cannot land on CAPI v1.14 at all without moving Kubernetes first. That inverts the ordering rule for exactly one hop, and it's the hop most likely to be discovered late.
| Release | Expected | What must be done by then |
|---|---|---|
| v1.14 | Aug 2026 | Management cluster on K8s v1.33+. Audit run. clusterctl convert available. |
| v1.15 | ~Dec 2026 | Docker* resources removed — CI/dev clusters must be on Dev* kinds. v1beta1 contract compatibility ends soon after. |
| v1.16 | April 2027 | v1beta1 unserved. All seven audit rows empty. Provider on a v1beta2-contract release line. |
| v1.17 | ~Aug 2027 | v1beta1 removed outright. No further reprieve. |
Two footnotes on that table. First, the v1.15 row is genuinely earlier and genuinely harder than it looks: CAPD is a test provider, so the normal API deprecation policy doesn't apply to it — removal, not deprecation. Second, the schedule has moved before. The v1.12 release notes said v1beta1 would be unserved in v1.14; it slipped to v1.16. "On track to" is doing real work in that sentence, and planning against the later date is reasonable — planning against a further slip is not.
On the escape hatch: the removal plan keeps a buffer release specifically so operators can flip v1beta1 back to served if they need more time. Treat that as a fire extinguisher, not a plan. You'd be running an explicitly unsupported configuration, and v1.17 removes the version outright regardless of what you set — so the hatch buys one release, once, and then the deadline is real.
The upside of owning the whole thing
There's a version of this post that reads as a complaint about API churn. It shouldn't. The v1beta1 → v1beta2 migration is unusually well-signposted: a dated tracking issue, a conversion tool, a documented field-by-field reference, versions kept present-but-unserved for three releases so managedFields cleanup can run, and a published escape hatch. Compare that to the average managed-Kubernetes deprecation email.
The reason it's still work is that a platform provisioning tenant clusters is a CAPI consumer, several times over — as a manifest author, an RBAC subject, a controller, and a ClusterClass publisher. That's the cost of building on an API instead of a UI. The compensation is that the schedule is yours: you decide which release you land on and when, you can rehearse the flip on your own staging cluster, and nobody upgrades your control plane on a Tuesday because their maintenance window said so.
Put the seven-row audit in CI this quarter. A grep that fails the build on a new x-k8s.io/v1beta1 reference costs ten minutes to write and is the difference between a migration and an incident.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, on a Cluster API fleet you control end to end. Star the repo on GitHub or deploy your first app today.



