Skip to main content

Hetzner Placement Groups and Cluster API: What Guarantees Your Control-Plane Anti-Affinity, and Why Autoscaled Workers Still Don't Get It

8 min readDora NodaDora Noda
Share
On this page

A Hetzner Placement Group guarantees something concrete: put up to 10 cloud servers in a "Spread" group, and Hetzner's scheduler will not let two of them land on the same physical host. It's a free, one-field API call, and it's been available since October 2021. Most Cluster API operators have never turned it on.

Here's the two-part finding worth knowing if you run a Cluster API fleet on Hetzner. First, Cluster API Provider Hetzner (CAPH) already wires this primitive into control-plane and etcd provisioning today — it's a solved problem, not a roadmap item. Second, the Kubernetes cluster-autoscaler's Hetzner provider still has no way to assign a placement group to a node it creates. The feature request has been open since July 2023, and it's currently sitting under a "lifecycle/rotten" label — GitHub's polite way of saying it's about to auto-close from inactivity. That means the worker capacity most likely to scale up fast and unpredictably — a tenant's burst traffic — is exactly the capacity with zero physical-host diversity guarantee.

What a Placement Group Actually Guarantees (and Doesn't)

The mechanics are narrow and worth stating precisely, because it's easy to read more into "anti-affinity" than Hetzner actually promises:

  • Type: only spread exists today. Every server added to a spread group is placed on a physical host different from every other server already in that group.
  • Cap: a maximum of 10 servers per placement group.
  • Ceiling: a maximum of 50 placement groups per project — so 500 servers' worth of guaranteed physical-host diversity, if you shard correctly.
  • Scope: servers in a group aren't required to share a location, but the guarantee only holds within a location — Hetzner's own guidance is to keep a group's members co-located, since spreading across two datacenters doesn't need this primitive at all (they're already on different hardware).
  • Cost: free. It's a scheduling constraint on server creation, not a billed resource.
  • Friction: an existing server has to be powered off before it can be added to a group after the fact — this is a create-time or maintenance-window decision, not something you retrofit onto a running fleet without a reboot.

What it does not guarantee is the part that matters for reading vendor claims (including the framing this piece originally set out to write) correctly: "different physical host" is not "different rack," "different power feed," "different network switch," or "different datacenter." A spread placement group protects against a single-server hardware fault — a dead disk, a failed PSU, a kernel panic on one box. It does nothing for a rack-level power event or a top-of-rack switch failure that could still take out every "different" host in your group if they happen to share that upstream dependency. Hetzner doesn't publish rack-level topology, so there's no way to build a rack-aware placement primitive on top of this API even if you wanted to — the physical-host boundary is the only boundary you get.

CAPH Already Solves This for Your Control Plane

This is the part worth correcting up front: placement groups aren't a gap Cluster API Provider Hetzner needs to grow into. They're already there. CAPH's own cluster-templates ship a dedicated patch, hcloud-hetznerCluster-placementGroup_patch.yaml, alongside a control-plane-specific counterpart. Syself's Apalla (Autopilot) documentation exposes it as three topology variables on the Cluster resource:

yaml
spec:
  topology:
    variables:
      - name: hcloudPlacementGroups
        value:
          - name: control-plane-pg
            type: spread
          - name: worker-pool-a-pg
            type: spread
      - name: controlPlanePlacementGroupNameHcloud
        value: control-plane-pg
  workers:
    machineDeployments:
      - class: worker-pool-a
        variables:
          overrides:
            - name: workerMachinePlacementGroupNameHcloud
              value: worker-pool-a-pg

Declare the groups once in topology.variables, then reference them by name from the control plane and from any individual MachineDeployment. That's the entire integration surface — no controller to write, no webhook to add.

This is exactly where the guarantee matters most. A standard HA control plane runs 3 etcd members; a larger fleet might run 5. Losing 2 of 3 (or 3 of 5) members to a single event costs etcd its quorum — the cluster can't accept writes until it's restored. A correlated physical-host failure that happens to land two etcd members on the same box is precisely the failure mode a spread placement group exists to rule out, and a 3-5 node control plane sits nowhere near the 10-server cap. There's no sharding to think about, no bookkeeping — declare one group, assign every control-plane and etcd node to it, done.

The Real Gap: Autoscaled Workers Land Wherever Hetzner Puts Them

The control-plane story is solved because a MachineDeployment is a static, operator-declared thing — you write the placement group name into the topology once and CAPH honors it on every machine that deployment creates. Autoscaled worker capacity doesn't work that way. The Kubernetes cluster-autoscaler — the generic, cloud-provider-agnostic component most self-hosted fleets actually use to scale worker pools up and down with demand — calls the Hetzner API directly to create nodes, outside of CAPH's MachineDeployment reconciliation. And that code path has no field for a placement group name.

kubernetes/autoscaler#5919, filed July 2023, asks for exactly this: let an operator specify a placement group for nodes the Hetzner autoscaler creates, the same way they can already specify a network or an SSH key. As of this writing it's still open, unassigned, and carrying a lifecycle/rotten label — the stage just before the bot auto-closes an issue nobody's picked up. Three years is long enough to treat this as a durable gap in the ecosystem, not a queue position.

The practical consequence is a split in coverage that doesn't line up with risk the way you'd want:

Node roleProvisioned byPlacement-group supportGuarantee at typical scale
Control plane / etcd (3-5 nodes)Static MachineDeployment via CAPH topologyYes — built into CAPH templatesFull: fits in one group, well under the 10-cap
Fixed-size worker poolStatic MachineDeployment via CAPH topologyYes — same mechanism, manually sharded past 10Full, but requires bookkeeping: a 30-node pool needs 3 groups, hand-assigned
Autoscaled worker poolcluster-autoscaler's Hetzner providerNo — issue #5919 open since 2023None: nodes land on whatever physical host Hetzner's scheduler picks

The nodes with the strongest anti-affinity guarantee are the ones that change least — a 3-node control plane you set up once. The nodes with none are the ones that change fastest and least predictably — a worker pool scaling out because a tenant's traffic spiked. That's backwards from where you'd want the guarantee to concentrate, but it's a direct consequence of which code path actually creates the server: CAPH's reconciler reads the topology; the autoscaler doesn't.

The 10-server cap does have a workaround for the static case — split a pool into multiple placement groups of ≤10 and hand-assign machines across them, up to the 50-groups/500-node project ceiling. That's real, and it's the same trick anyone hits scaling a control plane past a single etcd cluster. It just doesn't apply to the autoscaler path at all, because there's no group name to assign in the first place.

What This Means for a Self-Hosted Fleet's Actual Blast Radius

The honest operating guidance splits cleanly along the line the table above draws:

  • Turn placement groups on for control plane and etcd today. It's free, it's already wired into CAPH's topology variables, and it directly closes the correlated-failure risk that would otherwise cost you quorum. There's no reason not to.
  • Use them for any statically-sized MachineDeployment — a fixed worker pool, a dedicated cohort for a specific tenant tier — by sharding into groups of 10 and assigning them through the same workerMachinePlacementGroupNameHcloud override CAPH already supports.
  • Don't assume the same protection extends to autoscaled capacity. Until #5919 lands upstream — or a fleet operator forks the Hetzner cloud-provider plugin to add it — nodes the autoscaler creates get no physical-host diversity guarantee, full stop. The workaround, if this matters for a given workload, is to pre-size a MachineDeployment with a placement group instead of leaning on the generic autoscaler for that pool, trading elasticity for the guarantee.

This is a gap every CAPH-based operator running Hetzner as a Cluster API infrastructure provider shares, not a vendor-specific shortcoming to route around by switching providers — the same limitation sits in front of anyone wiring the stock cluster-autoscaler into a Hetzner-backed fleet, because the gap lives in the autoscaler's cloud-provider plugin, not in CAPH itself. A platform whose whole pitch is owning the machines underneath a git-push deploy still inherits this exact tradeoff: the primitive that would protect burst-scaled tenant capacity from a single-host failure exists at the API level, and the piece that would actually call it during an autoscale event doesn't exist yet.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, provisioned through the same Cluster API + Hetzner stack this post is about. 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