Skip to main content

No Terraform Required: KubeOne 1.13's One-Manifest Hetzner Control Planes vs Cluster API

10 min readDora NodaDora Noda
Share
On this page

For years, standing up Kubernetes on Hetzner Cloud with KubeOne meant running two tools that barely trusted each other: Terraform to birth the control-plane VMs and the API load balancer, then KubeOne to SSH in and kubeadm them into a cluster, with a terraform.tfstate file and two magic outputs (kubeone_hosts, kubeone_api) as the fragile handshake between them. KubeOne 1.13 cuts that handshake out. A new controlPlane.nodeSets block plus a cloudProvider.hetzner.controlPlane.loadBalancer section lets one kubeone apply provision the VMs and the load balancer straight from the manifest — no Terraform run, no state file, no output plumbing.

The verdict, up front, because the interesting question was never "does it work" but "when is this the right tool":

Classic KubeOne + TerraformKubeOne 1.13 managed control planeCluster API + CAPH
Tools to a running clusterTerraform + KubeOneKubeOne aloneclusterctl + controllers
Control-plane VMsTerraform provisionsKubeOne provisions (beta)MachineDeployments reconcile
API load balancerTerraform provisionsKubeOne creates/reusesHetznerCluster-managed
Workersmachine-controllermachine-controllerMachineDeployments
ReconciliationImperative CLI runsImperative CLI runsContinuous controllers
Clusters per manifestOneOneFleet-wide, GitOps-native
Best fitExisting TF estatesSingle-cluster ownerFleet owner

If you run one cluster on Hetzner Cloud and resent the Terraform sidecar, 1.13 is your release — with a beta flag that still says, verbatim, "DO NOT USE FOR PRODUCTION!" If you run a fleet, or plan to, the comparison that matters is against Cluster API, and there the Terraform question is a sideshow: the real axis is imperative installer versus declarative reconciliation.

What 1.13 actually shipped

KubeOne v1.13.0 landed on April 9, 2026 (latest patch v1.13.7, August 13), and its headline feature is Terraform-free Hetzner control plane provisioning, marked beta and explicitly not for production. The mechanics, per the upstream doc, work like this: when cloudProvider.hetzner.controlPlane is configured, kubeone apply looks up the named Hetzner Cloud load balancer, creates it if missing — TCP listener on 6443, health check on 6443, attached to your private network — then provisions control-plane servers through machine-controller's Hetzner driver, driven by the controlPlane.nodeSets spec. The LB finds its targets by label (kubeone_cluster_name, kubeone_role), so newly provisioned servers join the backend set with no member-registration call, and the LB's public IPv4 becomes apiEndpoint.host automatically.

The minimal shape is compact:

yaml
cloudProvider:
  hetzner:
    networkID: my-private-network
    controlPlane:
      loadBalancer:
        name: my-cluster-kubeapi   # default: "<CLUSTER_NAME>-kubeapi"
        type: lb11                 # default
        location: nbg1             # default
        publicIP: true             # default
 
controlPlane:
  nodeSets:
    - name: cp
      replicas: 3
      cloudProviderSpec:
        serverType: cx22
        location: nbg1
        image: ubuntu-24.04
        networks:
          - my-private-network

Three prerequisites survive the Terraform deletion: an HCLOUD_TOKEN with read/write access to servers, networks, and load balancers; a pre-existing private network referenced by networkID; and control-plane reachability over that network. Note what that means: KubeOne now owns VM and LB lifecycle, but the network itself is still somebody else's problem — the console, an earlier Terraform run, or hcloud CLI. "Terraform-free" describes the control-plane provisioning path, not the whole account.

Omit the controlPlane section and nothing changes: you supply apiEndpoint and static host IPs yourself, exactly as before, typically via Terraform's kubeone_hosts and kubeone_api outputs. The beta is opt-in per manifest, so existing estates upgrade without behavior change. And the pattern is already spreading: the OpenStack provider has a managed-control-plane mode (there Terraform still creates LB and networking while KubeOne takes the VMs), and v1.14.0 (July 27, 2026) added KubeOne-managed KubeVirt control planes — evidence this is the project's direction, not a one-provider experiment.

Before and after: the Terraform layer, removed

The classic flow had three moving parts and two trust boundaries. Terraform created the network, the control-plane servers, the load balancer, and the SSH key wiring, then exposed the cluster's front door through outputs. KubeOne read that state, SSH'd into the listed hosts, ran kubeadm, installed the CNI and machine-controller, and handed worker lifecycle to MachineDeployments-of-sorts (machine-controller MachineDeployment CRs — similar name, different project from Cluster API). Every cluster operation spanned two CLIs, two config languages, and one state file that both tools implicitly depended on.

Concretely, the managed-control-plane flow deletes:

  • The state file. No terraform.tfstate to store, lock, back up, or explain to the next hire. The manifest plus the Hetzner Cloud API's actual state is the whole world.
  • The output plumbing. kubeone_hosts and kubeone_api no longer exist as a contract; KubeOne populates controlPlane.hosts itself from the VMs it provisioned and derives the API endpoint from the LB it manages.
  • The version coupling. Terraform provider upgrades (hcloud provider schema changes, Terraform core deprecations) used to be able to break cluster operations independently of KubeOne. One binary, one release train, one changelog to watch.

What stays is everything KubeOne always was under the hood: a kubeadm driver that converges control planes over SSH on kubeone apply, plus machine-controller for workers. Day-2 control-plane upgrades are still "run the CLI again," not a controller noticing drift. That is the seam where the Cluster API comparison begins — because CAPI deletes a different thing. It doesn't delete a provisioning sidecar; it deletes the human re-running the installer.

What you give up against Cluster API

Put the same Hetzner hardware under Cluster API with the CAPH provider and the object model inverts. There is no installer run: MachineDeployment and HCloudMachine custom resources declare the desired fleet, and controllers reconcile actual toward declared continuously — including the control plane, via KubeadmControlPlane. KubeOne 1.13 narrows the provisioning gap (both tools now create VMs from a manifest-ish artifact) but leaves the lifecycle gap untouched:

  • No MachineDeployments for the control plane. KubeOne's nodeSets are consumed once per CLI run through machine-controller; there is no control loop watching them afterward. A dead control-plane VM waits for a human (or a cronjob, or an alert) where a CAPI MachineHealthCheck would remediate it.
  • No fleet-wide reconciliation. One manifest, one cluster, one kubeone apply at a time. CAPI's management cluster can own dozens of workload clusters from one GitOps repo; KubeOne has no "all my clusters match these manifests" operation.
  • No bare-metal story in this release. CAPH speaks Hetzner Robot (HetznerBareMetalHost, rescue-mode provisioning) for dedicated servers alongside Cloud VMs. KubeOne's managed control plane is a Cloud-VM feature; dedicated metal still goes through static hosts or Terraform.
  • Imperative day-2. Upgrades, etcd care (1.13 did add a handy kubeone etcd command group — members, defrag, snapshots), certificate rotation: all CLI invocations you schedule, versus controllers you configure.

None of this is a criticism of KubeOne's choice; it is the choice. An installer that runs to completion and exits has a failure surface you can hold in your head: it failed or it didn't, and the log says where. A controller fleet reconciling forever has failure modes measured in "why did it do that at 3 a.m." Nobody should graduate from one to the other for fashion reasons. The honest question is which failure surface matches your team — which is the next section.

Which operator each tool fits

The sensitivity variable is not taste; it is cluster count crossed with day-2 automation appetite. Three typical cases cover nearly everyone on Hetzner hardware:

One cluster, small team, Terraform fatigue. This is the operator 1.13 was built for. You run a single production cluster on Hetzner Cloud, your Terraform footprint exists only to feed KubeOne, and every provider upgrade is pure toil. The managed control plane collapses your stack to one manifest, one credentials file, one binary. Accept the beta label by testing it on a dev cluster now (more on that below) and plan the production cutover for GA.

One cluster, Terraform everywhere else. If Terraform already manages your networks, DNS, firewall rules, and non-Kubernetes infrastructure, deleting it from exactly one path buys little and costs consistency. Your team thinks in terraform plan; keep the kubeone_hosts outputs, keep one IaC language, and let the KubeOne manifest stay dumb. The new fields are opt-in precisely so this operator changes nothing.

A fleet, or a fleet-in-waiting. Two clusters that must upgrade in lockstep, per-tenant workload clusters, dev/staging/prod parity enforced rather than hoped for, dedicated bare metal in the mix, GitOps as policy rather than aspiration — this is Cluster API territory. CAPH gives you declarative MachineDeployments across Cloud and Robot metal, fleet-wide reconciliation from a management cluster, and MachineHealthCheck remediation, at the price of CAPI's famously steep learning curve and a management cluster to babysit. KubeOne cannot grow into this; adopting it for cluster one of a future fleet just schedules a migration.

Notice the shape of that guidance: the Terraform question resolves inside the single-cluster row. Once you need fleet semantics, "does my installer need Terraform" stops being the binding constraint, because you've stopped installing clusters by hand at all.

Production checklist before you trust the beta

The changelog warning is unambiguous — beta, not for production — and v1.13.7 changed nothing about that status. If you're evaluating on a dev cluster (which you should be), verify these before the GA flips your planning:

  • LB defaults fit your topology. lb11 in nbg1 with a public IP is a sane default, not a universal one. Private-only API endpoints, other locations, and existing-LB reuse (supported — a pre-existing LB is adopted as-is) all need explicit config and a test run.
  • The private network is pre-provisioned. KubeOne will not create it. Whatever creates it — console click, hcloud CLI, retained Terraform — is now the one remaining piece of infra outside the manifest; document it or it becomes tribal knowledge.
  • Token scope is broad by design. Read/write on servers, networks, and load balancers is the minimum the feature functions with. Scope the token to the right project, rotate it, and keep it out of the manifest (credentials file or HCLOUD_TOKEN, never inline).
  • Watch v1.14's trajectory, not just v1.13's label. Managed KubeVirt control planes in 1.14 plus the OpenStack managed mode suggest the beta is a staging area for a cross-provider GA, not an experiment that might be reverted. Track the changelog for the production-readiness flip rather than re-litigating the architecture.

The installer isn't dying; the sidecar is

Step back and the release reads as part of a slow industry sort. Single-cluster installers are absorbing just enough provisioning to stand alone — KubeOne on Hetzner, OpenStack, and KubeVirt — while fleet operators consolidate on Cluster API's controller model. The middle ground, "installer plus a general-purpose IaC tool holding its hand," is what's being squeezed: too complex for one cluster, too manual for fifty. KubeOne 1.13 picks its side clearly. It wants to be the whole answer for the single-cluster owner, and it prices that ambition honestly with a beta flag.

For teams on that path, the move is concrete: spin a dev cluster on the managed control plane this week, keep Terraform on production until GA, and re-audit the day you add cluster two — because cluster two is when the question stops being "which installer" and starts being "which reconciliation loop owns my fleet."

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex