Get Rewarded! We will reward you with up to €50 credit on your account for every tutorial that you write and we publish!

Cost-optimal Kubernetes autoscaling on Hetzner Cloud with Karpenter

profile picture
Author
Jannes Stubbemann
Published
2026-07-14
Time to read
7 minutes reading time

About the author- Founder and CEO at Paperclip.inc, building the 24/7 control plane for AI-run companies.

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 kubeadm or 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 kubeadm join token, or a Talos worker machine config). You will put this in a Secret in Step 3.
  • kubectl and helm v3.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.

See available Karpenter versions

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.yaml

Now 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.

See available karpenter-provider-hetzner versions

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-token

Check that the controller is running:

kubectl -n karpenter get pods

Wait 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 join with a token. Generate a fresh token on a control plane node with kubeadm 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.yaml in the kubectl command below.

kubectl -n karpenter create secret generic worker-bootstrap \
  --from-file=userData=./worker-cloud-init.yaml

A 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 hardware

Apply it:

kubectl apply -f nodeclass.yaml
kubectl wait --for=condition=Ready hcloudnodeclass/default --timeout=120s

If 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: 1m
kubectl apply -f nodepool.yaml

Karpenter 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=1

Watch a NodeClaim appear, then a node:

kubectl get nodeclaims -w
kubectl get nodes
kubectl get pods

Within 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 pods

After 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 NodePool limits and taints to isolate workloads.
  • Expose the provider's Prometheus metrics (karpenter_hetzner_*) and scrape them with a ServiceMonitor.
  • Read the provider documentation and source: github.com/paperclipinc/karpenter-provider-hetzner
  • Background and design notes on the provider: Paperclip.inc
License: MIT
Want to contribute?

Get Rewarded: Get up to €50 in credit! Be a part of the community and contribute. Do it for the money. Do it for the bragging rights. And do it to teach others!

Report Issue
Try Hetzner Cloud

Get €20/$20 free credit!

Valid until: 31 December 2026 Valid for: 3 months and only for new customers
Get started
Want to contribute?

Get Rewarded: Get up to €50 credit on your account for every tutorial you write and we publish!

Find out more