Introduction
This tutorial sets up Karpenter node autoscaling on a Kubernetes cluster running on Hetzner Cloud. Instead of maintaining fixed node pools of one server type, Karpenter looks at the pods that cannot be scheduled and launches the cheapest Hetzner server that fits them. When a node goes idle, Karpenter removes it. The result is a cluster that tracks demand and keeps the bill close to what the workload actually needs.
Karpenter talks to an infrastructure backend through a cloud provider. This tutorial uses the open-source Karpenter provider for Hetzner, which provisions Hetzner Cloud servers as nodes and selects instance types from Hetzner's live pricing.
By the end you will have Karpenter provisioning a node on demand, scheduling a workload onto it, and consolidating the node away when the workload is gone.
Prerequisites
- A running Kubernetes cluster on Hetzner Cloud (control plane reachable, a CNI installed). Tutorials such as the ones for
kubeadmor kube-hetzner get you here. You can also check out the Terraform tutorial. - The Hetzner Cloud Controller Manager installed, and a Hetzner private network the new nodes will join.
- A way for a fresh worker to join your cluster (a
kubeadmjoin token, or a Talos worker machine config). You will put this in a Secret in Step 3. kubectlandhelmv3.8+ on your machine, configured against the cluster.- A Hetzner Cloud API token with read and write access, created in the Hetzner Console.
Example terminology
<HCLOUD_TOKEN>: your Hetzner Cloud API token<NETWORK_ID>: the numeric ID of your private network (hcloud network list)my-cluster: a name for this cluster, used to label the servers Karpenter manages
Step 1 - Store the Hetzner API token
Karpenter's Hetzner provider reads your API token from a Kubernetes Secret. Create a namespace for Karpenter and store the token there.
kubectl create namespace karpenter
kubectl -n karpenter create secret generic hcloud-token \
--from-literal=token="<HCLOUD_TOKEN>"The token never leaves the cluster and is not written to any custom resource.
Step 2 - Install Karpenter and the Hetzner provider
Karpenter ships its core custom resource definitions (NodePool and NodeClaim) separately from any provider. Install them first.
KARPENTER_VERSION=v1.14.0
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/karpenter/${KARPENTER_VERSION}/pkg/apis/crds/karpenter.sh_nodepools.yaml
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/karpenter/${KARPENTER_VERSION}/pkg/apis/crds/karpenter.sh_nodeclaims.yamlNow install the provider from its OCI Helm chart. Set clusterName to a name unique to this cluster: the provider labels every server it creates with this value, so two clusters in the same Hetzner project never manage each other's nodes.
helm install karpenter-provider-hetzner \
oci://ghcr.io/paperclipinc/charts/karpenter-provider-hetzner --version 1.0.0 \
--namespace karpenter \
--set clusterName=my-cluster \
--set auth.secretRef.name=hcloud-tokenCheck that the controller is running:
kubectl -n karpenter get podsWait until the karpenter-provider-hetzner pod reports Running.
Step 3 - Provide a worker bootstrap Secret
A new server has to join your cluster on first boot. The provider passes a userData document to the server and reads it from a Secret, so no join credentials sit in a custom resource.
What goes in the document depends on how your cluster boots its nodes:
- Ubuntu / cloud-init: a cloud-config that installs the kubelet and runs
kubeadm joinwith a token. Generate a fresh token on a control plane node withkubeadm token create --print-join-command. - Talos: a worker machine config produced by
talosctl gen config(or reused from your existing worker configuration).
Store your document in a Secret. For an Ubuntu cloud-init example:
If you used the Terraform tutorial, provide the path to your
cloud-init-worker.yamlin the kubectl command below.
kubectl -n karpenter create secret generic worker-bootstrap \
--from-file=userData=./worker-cloud-init.yamlA minimal worker-cloud-init.yaml for a kubeadm cluster looks like this. Replace the join command, CA hash, and token with the values from kubeadm token create --print-join-command:
#cloud-config
runcmd:
- kubeadm join <CONTROL_PLANE_ENDPOINT>:6443 --token <TOKEN> --discovery-token-ca-cert-hash sha256:<HASH>See the provider's Talos and Ubuntu bootstrap guides for complete documents.
Step 4 - Define how nodes are built
An HCloudNodeClass describes the servers Karpenter creates: which image, which network, how to bootstrap. Save the following as nodeclass.yaml, set your <NETWORK_ID>, and pick the image family that matches your bootstrap document.
apiVersion: karpenter.hetzner.cloud/v1alpha1
kind: HCloudNodeClass
metadata:
name: default
spec:
locations: ["nbg1"] # one or more Hetzner locations
networkID: <NETWORK_ID> # your private network ID
imageSelector:
family: ubuntu # or "talos"
userDataSecretRef:
namespace: karpenter
name: worker-bootstrap
key: userData
placementGroupStrategy: spread # spread nodes across Hetzner hardwareApply it:
kubectl apply -f nodeclass.yaml
kubectl wait --for=condition=Ready hcloudnodeclass/default --timeout=120sIf the node class does not become ready, kubectl describe hcloudnodeclass default shows an Event explaining why (a missing network, an unresolved image, or a bad Secret reference).
Step 5 - Define what Karpenter may launch
A NodePool sets the limits and the disruption rules. Save this as nodepool.yaml:
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: default
spec:
template:
spec:
nodeClassRef:
group: karpenter.hetzner.cloud
kind: HCloudNodeClass
name: default
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: karpenter.sh/capacity-type
operator: In
values: ["on-demand"]
limits:
cpu: "16"
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 1mkubectl apply -f nodepool.yamlKarpenter will now provision the cheapest Hetzner server that fits any unschedulable pods, up to a total of 16 vCPUs, and remove nodes that sit empty or underused for a minute.
Step 6 - Watch it scale
Deploy a workload that does not fit on the current nodes so Karpenter has to add capacity:
kubectl create deployment inflate --image=registry.k8s.io/pause:3.10 --replicas=5
kubectl set resources deployment inflate --requests=cpu=1Watch a NodeClaim appear, then a node:
kubectl get nodeclaims -w
kubectl get nodes
kubectl get podsWithin a couple of minutes a new Hetzner server is created, joins the cluster, and the inflate pods schedule onto it. Confirm the server in the Hetzner Console or with hcloud server list.
Now scale the workload back down and watch consolidation remove the node:
kubectl delete deployment inflate
kubectl get nodes -w
kubectl get podsAfter consolidateAfter elapses, Karpenter drains and deletes the now-empty node, and the server is removed from your Hetzner project.
Step 7 - Cheaper nodes on Arm (optional)
Hetzner's Ampere Arm64 (CAX) servers are often the lowest-cost option. Add a second NodePool that only offers arm64, and let your Arm64-capable workloads land there:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["arm64"]Pods choose their architecture with a nodeSelector on kubernetes.io/arch. As long as your container images are multi-arch, each workload runs on whichever pool is cheaper for it.
Conclusion
You installed Karpenter and the Hetzner provider, told Karpenter how to build and bootstrap nodes, and watched it provision a server on demand and consolidate it away when the work was done. Your cluster now scales by cost rather than by fixed pools.
Next steps:
- Add per-team
NodePoollimits and taints to isolate workloads. - Expose the provider's Prometheus metrics (
karpenter_hetzner_*) and scrape them with aServiceMonitor. - Read the provider documentation and source: github.com/paperclipinc/karpenter-provider-hetzner
- Background and design notes on the provider: Paperclip.inc