mysticaltech/terraform-hcloud-kube-hetzner
HCLOptimized and Maintenance-free Kubernetes on Hetzner Cloud in one command!

Kube-Hetzner
Production-Ready Kubernetes on Hetzner Cloud
HA by default • Auto-upgrading • Cost-optimized
A highly optimized, easy-to-use, auto-upgradable Kubernetes cluster powered by k3s on openSUSE Leap Micro (default) / MicroOS (legacy)
deployed for peanuts on Hetzner Cloud
[!TIP]
🤖 This project is built to be operated by AI agents
One command teaches Claude Code, Codex, Cursor, and 70+ other agents everything about kube-hetzner:
npx skills add kube-hetzner/terraform-hcloud-kube-hetznerThen just talk to your agent:
/kh-assistantanswers any configuration or debugging question with live repo knowledge and routes you to the right tool —/migrate-v2-to-v3drives the whole v2 → v3 upgrade with protected-infrastructure plan gates,/upgrade-clusterhandles version bumps and rollouts,/debug-noderescues broken nodes. Deploying, upgrading, and debugging become conversations.
✨ Highlights
- Production-ready Kubernetes on Hetzner Cloud: highly optimized, easy to maintain, secure, and automatically upgrades both nodes and Kubernetes.
- k3s default, RKE2 supported: k3s is the lightweight default; RKE2 is a first-class Kubernetes distribution choice in this module.
- HA and autoscaling: odd control-plane quorum, static agent pools, Cluster Autoscaler nodepools, and the exhaustive
kube.tf.exampleshowcase are documented together. - Private networking and NAT: Hetzner private networking, private control-plane load balancers, NAT router patterns, and narrowed SSH/API firewall sources are supported.
- Leap Micro first: new nodes default to openSUSE Leap Micro; MicroOS remains supported for upgrades/legacy nodes.
- Upgrade automation: Kured handles HA-aware OS reboots; system-upgrade-controller manages k3s upgrades.
- Evidence-backed releases: v3 release gates include live Hetzner apply, upgrade, health, NAT, autoscaler, RKE2, and destroy evidence in
docs/v3-release-evidence.md. - AI-assisted everything: install the agent skills with
npx skills add kube-hetzner/terraform-hcloud-kube-hetzner—/kh-assistantis your kube-hetzner expert in Claude Code, Codex, or Cursor, and/migrate-v2-to-v3,/upgrade-cluster,/debug-nodeautomate the heavy lifting. See AI-assisted operations.
🆕 What's New in v3
The biggest release in kube-hetzner history: months of hardening, every flagship path applied live against real Hetzner infrastructure before shipping.
| What you get | |
|---|---|
| ⚔️ RKE2, first-class | k3s stays the lightweight default; RKE2 is now a fully supported distribution choice — same module, same workflow. |
| 🐧 Leap Micro by default | New nodes boot openSUSE Leap Micro; your existing MicroOS nodes keep running untouched. |
| 🕸️ Tailscale node transport | Opt-in node_transport_mode = "tailscale": secure single-network clusters and private multinetwork scale-out, with reference designs past 100 and up to 10,000 nodes. |
| 📌 Deterministic addon versions | Unset means a reviewed, pinned version matrix — latest is an explicit opt-in. No more surprise upstream bumps mid-apply. |
| 🏷️ Declarative node annotations | Per-pool and per-node Kubernetes annotations, autoscaled nodes included — Longhorn disk bootstrap in three lines of config. |
| 🧱 Plan-time guardrails | Cross-variable validation catches invalid topologies at terraform plan — not on a half-built cluster. |
| 🌐 Networking, unlocked | Active nodepool network_id, subnet modes, Cilium Gateway API + dual-stack, egress gateway HA, embedded registry mirror, hardened NAT-router bastion. |
| 💾 Multi-volume nodes | Attach and mount multiple Hetzner Volumes per node, declaratively. |
| 🧹 One-command teardown | scripts/destroy.sh absorbs the known destroy races, then reports any orphaned resources — read-only, nothing deleted without you. |
| 🛡️ SELinux enforcing, documented | Every policy rule now carries its provenance (docs/selinux.md); udica ships on every node for targeted workload policies. |
| ✅ Evidence-backed release | Fresh deploys, an in-place v2 upgrade with zero destroyed infrastructure, NAT failover, autoscaler, RKE2 — all live-proven in docs/v3-release-evidence.md. |
[!TIP] Migrating from v2? Let your AI agent drive. The
/migrate-v2-to-v3skill ships in this repo and works with Claude Code, Codex, Cursor, and any agent that supports agent skills. It rewrites yourkube.tfto the v3 contract, runs the protected-infrastructure plan gate, and reviews the plan with you — nothing is applied until you say so. Prefer hands-on? Start withMIGRATION.md.
The full story: v3.0.0 release notes · CHANGELOG · MIGRATION.md
📚 Table of Contents
- Highlights
- What's New in v3
- Quickstart
- Upgrading & Migration
- Features & Support Matrix
- Configuration & Documentation
- Project Background
- Usage & Operations
- Examples & Recipes
- Debugging
- Takedown
- Contributing & Community
🚀 Quickstart
Prerequisites
| Platform | Installation Command |
|---|---|
| Homebrew (macOS/Linux) | brew install opentofu hashicorp/tap/packer kubectl hcloudOptional Terraform CLI: brew install hashicorp/tap/terraform |
| Arch Linux | yay -S terraform packer kubectl hcloud |
| Debian/Ubuntu | sudo apt install terraform packer kubectl |
| Fedora/RHEL | sudo dnf install terraform packer kubectl |
| Windows | choco install terraform packer kubernetes-cli hcloud |
Required tools: Terraform or OpenTofu >= 1.10.1 (
brew install opentofu), packer (initial setup only), kubectl, hcloud. The module requireshetznercloud/hcloudprovider >= 1.62.0.
OpenTofu is officially supported. Pull requests are validated in CI with both Terraform and OpenTofu, including real Hetzner preset apply/health/destroy tests when Hetzner E2E is enabled.
Quick Start
| 1️⃣ | Create a Hetzner project at console.hetzner.cloud and grab an API token (Read & Write) |
| 2️⃣ | Generate an SSH key pair (passphrase-less ed25519) — or see SSH options |
| 3️⃣ | Run the setup script — creates your project folder and OS snapshots (Leap Micro recommended): |
tmp_script=$(mktemp) && curl -sSL -o "${tmp_script}" https://raw.githubusercontent.com/kube-hetzner/terraform-hcloud-kube-hetzner/master/scripts/create.sh && chmod +x "${tmp_script}" && "${tmp_script}" && rm "${tmp_script}"
The downloaded kube.tf.example is an exhaustive showcase, not a minimal
starter: it currently defines 3 control-plane pools (3 nodes) and 6 active static
agent pools (7 agent nodes) covering storage, egress, ARM, and node-map examples.
Trim the pools and feature examples you do not need before first apply.
Fish shell version
set tmp_script (mktemp); curl -sSL -o "{tmp_script}" https://raw.githubusercontent.com/kube-hetzner/terraform-hcloud-kube-hetzner/master/scripts/create.sh; chmod +x "{tmp_script}"; bash "{tmp_script}"; rm "{tmp_script}"
Save as alias for future use
alias createkh='tmp_script=$(mktemp) && curl -sSL -o "${tmp_script}" https://raw.githubusercontent.com/kube-hetzner/terraform-hcloud-kube-hetzner/master/scripts/create.sh && chmod +x "${tmp_script}" && "${tmp_script}" && rm "${tmp_script}"'
What the script does
mkdir /path/to/your/new/folder
cd /path/to/your/new/folder
curl -sL https://raw.githubusercontent.com/kube-hetzner/terraform-hcloud-kube-hetzner/master/kube.tf.example -o kube.tf
curl -sL https://raw.githubusercontent.com/kube-hetzner/terraform-hcloud-kube-hetzner/master/packer-template/hcloud-leapmicro-snapshots.pkr.hcl -o hcloud-leapmicro-snapshots.pkr.hcl
curl -sL https://raw.githubusercontent.com/kube-hetzner/terraform-hcloud-kube-hetzner/master/packer-template/hcloud-microos-snapshots.pkr.hcl -o hcloud-microos-snapshots.pkr.hcl
export HCLOUD_TOKEN="your_hcloud_token"
packer init hcloud-leapmicro-snapshots.pkr.hcl
for distro in k3s rke2; do
packer build -var "selinux_package_to_install=${distro}" hcloud-leapmicro-snapshots.pkr.hcl
done
# (optional legacy)
# packer init hcloud-microos-snapshots.pkr.hcl
# packer build hcloud-microos-snapshots.pkr.hcl
hcloud context create <project-name>
| 4️⃣ | Customize your kube.tf — full reference in terraform.md |
🎯 Deploy
cd <your-project-folder>
terraform init --upgrade
terraform validate
terraform plan
terraform apply -auto-approve
OpenTofu works the same way:
tofu init --upgrade
tofu validate
tofu plan
tofu apply -auto-approve
~5 minutes later: Your cluster is ready! 🎉
⚠️ Once Terraform manages your cluster, avoid manual changes in the Hetzner UI. Use
hcloudCLI to inspect resources.
🔄 Upgrading & Migration
v2 -> v3 migration
For v2.x -> v3.x, start with MIGRATION.md, the contract/variable map, and docs/v2-to-v3-migration.md, the guided walkthrough. The live release evidence includes a v2.21.0 -> v3 in-place upgrade with 0 destroy/replace of hcloud infrastructure for the standard path; read the guide before planning and apply only after every planned resource action is understood.
Production operators should read the Production in-place upgrades: safety model before applying.
Fastest path: if you use Claude Code or a compatible agent, run the
/migrate-v2-to-v3skill from a checkout of this repo — it applies the rename map, runs the protected-infrastructure plan gate, and reviews the plan with you before anything is applied.
From a local kube-hetzner checkout, audit the Terraform root, then run:
uv run python /path/to/kube-hetzner/scripts/v2_to_v3_migration_assistant.py --root .
terraform init -upgrade
terraform plan
Only apply after reviewing all planned resource actions. Release-gate evidence is recorded in docs/v3-release-evidence.md.
v3 Readiness Checklist
Before applying a v3 upgrade, confirm:
- Current state is backed up with
terraform state pull. - Removed v2 inputs are gone and renamed booleans with inverted meaning are reviewed.
- Addon version policy is reviewed: unset addon version variables use deterministic module defaults, while
latestopts back into upstream floating behavior. - In-place v2 upgrades keep the default
network_subnet_mode = "per_nodepool"unless subnet resource changes are intentional. terraform validateortofu validatepasses before planning.terraform planhas no unexpecteddelete,replace, orforces replacementactions.- Network, subnet, load balancer, NAT router, placement group, server, and volume changes are intentional.
- Private-only, Robot/vSwitch, external-network, Tailscale/overlay, Longhorn, and autoscaler clusters have a rollback or blue/green plan.
AI-assisted migration
This repo ships agent skills under .claude/skills/ for Claude Code, Codex, Cursor, and any skills-capable agent. Install them in one command:
npx skills add kube-hetzner/terraform-hcloud-kube-hetzner
/kh-assistant is the flagship: a kube-hetzner expert with live repo knowledge that answers configuration and debugging questions and recommends the right specialized skill for the job. /migrate-v2-to-v3 guides the whole v2 -> v3 upgrade with protected-infrastructure plan gates; /upgrade-cluster and /debug-node cover live upgrades and rescue-mode node debugging.
Usage sketch:
/migrate-v2-to-v3 <terraform-root>
/upgrade-cluster <terraform-root>
/debug-node
| Skill | Purpose |
|---|---|
/migrate-v2-to-v3 <terraform-root> |
Guided v2 to v3 migration and plan review |
/kh-assistant |
Interactive help for configuration and debugging |
/upgrade-cluster <terraform-root> |
Safety-first workflow for live module and Kubernetes cluster upgrades |
/debug-node |
Rescue-mode workflow for unreachable Hetzner nodes, SSH failures, cloud-init failures, or provisioning hangs |
/review-pr <num> |
Security-focused PR review |
/test-changes |
Run terraform fmt, validate, plan |
PRs to improve these skills are welcome! See .claude/skills/ for the skill definitions.
In-major Kubernetes and OS upgrades
OS Upgrades (Leap Micro / MicroOS)
Handled by Kured—safe, HA-aware reboots. Configure timeframes via Kured options.
Set enable_kured = false only when reboot orchestration is managed externally;
automatically_upgrade_os controls the host update timer, not Kured deployment.
K3s Upgrades
Managed by system-upgrade-controller. Customize the upgrade plan template.
Set enable_system_upgrade_controller = false only when the controller and plans
are managed externally; automatically_upgrade_kubernetes controls upgrade
activity, not system-upgrade-controller deployment.
Disable Automatic Upgrades
# Disable OS upgrades (required for <3 control planes)
automatically_upgrade_os = false
# Disable k3s upgrades
automatically_upgrade_kubernetes = false
Manual upgrade commands
Selective k3s upgrade:
kubectl label --overwrite node <node-name> k3s_upgrade=true
kubectl label node <node-name> k3s_upgrade- # disable
Or delete upgrade plans:
kubectl delete plan k3s-agent -n system-upgrade
kubectl delete plan k3s-server -n system-upgrade
Manual OS upgrade:
kubectl drain <node-name>
ssh root@<node-ip>
systemctl start transactional-update.service
reboot
Component Upgrades
Use the kustomization_backup.yaml file created during installation:
- Copy to
kustomization.yaml - Update source URLs to latest versions
- Apply:
kubectl apply -k ./
Module version upgrades
Update version in your kube.tf and run terraform apply.
Migrating from 1.x to 2.x
- Run
createkhto get new packer template - Update version to
>= 2.0 - Remove
extra_packages_to_installandopensuse_microos_mirror_link(moved to packer) - Run
terraform init -upgrade && terraform apply
✅ Features & Support Matrix
Feature Highlights
🚀 Core Platform
🌐 Networking & CNI
⚖️ Load Balancing
|
🔄 High Availability
💾 Storage
🔒 Security & Operations
|
v3 Support Levels
| Area | Support level | Notes |
|---|---|---|
| k3s on Leap Micro | Stable default | Recommended path for new clusters. |
| RKE2 on Leap Micro | Supported | Heavier distribution; basic RKE2 preset covered by CI, advanced RKE2 combinations validated manually per release. |
| MicroOS | Legacy/upgrade support | Existing clusters remain supported; new nodepools default to Leap Micro. |
| OpenTofu | Supported | Validate with tofu init, tofu validate, and tofu plan before applying. |
| Addon version defaults | Reviewed deterministic defaults | Unset addon version variables use the module's reviewed version matrix; set latest only when you intentionally want upstream latest behavior. |
| Cilium dual-stack | Supported | Preferred advanced CNI path. |
| Cilium Gateway API | Supported opt-in | cilium_gateway_api_enabled = true installs standard Gateway API CRDs and enables Cilium Gateway API. Requires Cilium with kube-proxy replacement. |
| Tailscale node transport | Static/plan validated; live E2E pending | node_transport_mode = "tailscale" has static validation and plan-matrix coverage; recommended for evaluation, not yet certified for production topologies. |
| Embedded registry mirror | Supported opt-in | Enables k3s/RKE2's embedded Spegel mirror for trusted larger clusters. |
| Cilium multinetwork public overlay | Experimental preview | Gated by enable_experimental_cilium_public_overlay; not production-supported until live datapath validation passes. |
| Flannel multinetwork scale over Hetzner Networks | Static/plan validated through Tailscale transport; live E2E pending | Flannel is the first supported CNI for node_transport_mode = "tailscale"; Cilium multinetwork remains the separate experimental public-overlay preview until live datapath coverage promotes it. |
| Cloudflare Zero Trust Access/Tunnel | Documented external access pattern | Use user-managed Cloudflare Access/Tunnel for operator, SSH, Rancher, or ingress access. kube-hetzner does not manage Cloudflare resources or support Cloudflare Mesh/WARP as node transport in v3. |
| User-owned Tailscale/ZeroTier/WireGuard/WARP access | Supported external pattern | Use generic hooks when you only want Terraform/operator access and do not want kube-hetzner to manage node transport. |
| Robot/vSwitch coupling | Advanced/special-case | Prefer blue/green migration and review route exposure carefully. |
Which Topology Should I Use?
| Need | Recommended v3 topology |
|---|---|
| Small dev cluster | Single control plane, one agent pool, no ingress unless needed. |
| Normal HA | 3 control planes, 2+ agents, one primary Hetzner Network, public API LB restricted to your source CIDRs or an explicit secure endpoint. |
| Private-only | nat_router plus private control-plane LB on the primary Network. |
| Secure operator/API access | node_transport_mode = "tailscale" with public API/SSH firewall sources closed. |
| Cloudflare-protected operator/app access | Keep Tailscale or Hetzner private transport underneath; put user-managed Cloudflare Access/Tunnel in front of kube API, SSH, Rancher, or ingress endpoints. |
| More than 100 Cloud nodes | Tailscale node transport plus network_scope = "external" shards, one Hetzner Network per 100-node budget. |
| Very large reference | Autoscaler-first Tailscale multinetwork; see the 200-node and 10k-node examples. |
| Cilium Gateway API | Cilium, enable_kube_proxy = false, cilium_gateway_api_enabled = true. |
| Heavy image-pull pressure | embedded_registry_mirror.enabled = true on trusted clusters. |
Full guide: docs/v3-topology-recommendations.md.
Public node join endpoints require a real public API host: either
control_plane_endpoint, a public control-plane load balancer, or public
IPv4/IPv6 on the control-plane nodes. IPv6-only public joins are valid; private
control planes without one of those hosts are rejected during validation.
⚙️ Configuration & Documentation
Configuration overview
Reference configuration starts in kube.tf.example. It is the exhaustive showcase called out in the Quickstart, so trim unused pools and feature examples before first apply.
Most components use Helm Chart definitions via k3s Helm Controller.
Plan-Time Validation
Kube-Hetzner uses Terraform/OpenTofu validations as the module contract: self-contained input validation blocks plus the hard terraform_data.validation_contract precondition surface for cross-variable rules. Run terraform plan or tofu plan before every apply; invalid combinations fail before resources are created.
The validation layer checks pure configuration invariants: non-empty tokens and SSH keys, supported regions and locations, CIDR syntax and IP-family pairs, nodepool name/count rules, odd control-plane quorum, Hetzner network/subnet/placement-group limits, multi-network join requirements, autoscaler boundaries, Cilium-only options, load balancer dependencies, firewall source formats, Robot/vSwitch/NAT requirements, audit settings, YAML snippets, and attached volume definitions.
When validating the module itself with both CLIs in the same checkout, run OpenTofu in a temporary copy so its ignored lock file and plugin cache never disturb Terraform's local state:
tmpdir="$(mktemp -d)"
rsync -a --exclude .git --exclude .terraform --exclude .terraform-tofu ./ "$tmpdir"/
(cd "$tmpdir" && tofu init -backend=false -input=false && tofu validate -no-color)
rm -rf "$tmpdir"
Provider/runtime assertions still belong in resource preconditions, postconditions, or checks. If an invariant can be decided from module inputs, it should be enforced at this plan-time validation layer.
Documentation Index
| Resource | Purpose |
|---|---|
docs/llms.md |
Comprehensive variable and topology reference for assistants and operators. |
docs/terraform.md |
Generated Terraform/OpenTofu inputs, outputs, resources, and providers. |
docs/ssh.md |
SSH keys, firewall sources, dynamic IP handling, and connection behavior. |
docs/private-network-egress.md |
NAT router setup and private-network egress patterns. |
docs/add-robot-server.md |
Hetzner Robot dedicated server integration. |
docs/v3-release-evidence.md |
Live v3 release evidence matrix and known gaps. |
site-docs/index.md and site-docs/configuration.md |
Lightweight generated site docs from README.md and kube.tf.example. |
docs/v3-topology-recommendations.md |
v3 topology chooser for dev, HA, private-only, Tailscale, Cloudflare, multinetwork, Gateway API, Robot/vSwitch, and registry mirror patterns. |
🧭 Project Background
Hetzner Cloud offers exceptional value with data centers across Europe and the US. This project creates a highly optimized Kubernetes installation that's easy to maintain, secure, and automatically upgrades both nodes and Kubernetes—functionality similar to GKE's Auto-Pilot.
We are not Hetzner affiliates, but we strive to be the optimal solution for deploying Kubernetes on their platform.
Built on the shoulders of giants:
- openSUSE Leap Micro — Stable, immutable container OS with transactional updates
- openSUSE MicroOS — Rolling, immutable container OS (legacy/upgrade support)
- k3s — Certified, lightweight Kubernetes distribution
Why Leap Micro over Ubuntu?
| Feature | Benefit |
|---|---|
| Immutable filesystem | Most of the OS is read-only—hardened by design |
| Transactional updates | Atomic upgrades with rollback |
| Stable release cadence | Predictable base OS for production |
| BTRFS snapshots | Automatic rollback if updates break something |
| Kured support | Safe, HA-aware node reboots |
MicroOS remains supported for upgrades/legacy nodes. If you want MicroOS for a new nodepool, set
os = "microos"explicitly.
Why k3s?
| Feature | Benefit |
|---|---|
| Certified Kubernetes | Automatically synced with upstream k8s |
| Single binary | Deploy with one command |
| Batteries included | Built-in helm-controller |
| Easy upgrades | Via system-upgrade-controller |
🧰 Usage & Operations
Security
MicroOS / Leap Micro Hardening
- Immutable base OS: Leap Micro and MicroOS use transactional updates and read-only system partitions by default, reducing host drift and limiting persistence for unauthorized changes.
- Reduced host surface: Cluster nodes are treated as appliance-style Kubernetes hosts; operational changes should flow through Terraform and Kubernetes manifests rather than ad-hoc host mutation.
- SELinux integration: The module includes SELinux handling for K3s/RKE2 bootstrap paths, with explicit controls and troubleshooting guidance for strict environments.
Network Isolation
- Default deny posture for cluster ingress: Firewall rules are explicit and can be narrowed to trusted source ranges (
myipv4/allowlists) for SSH and Kubernetes API exposure. - Private cluster topology support: You can run with private networking and NAT routing patterns to minimize directly exposed node interfaces.
- Load balancer boundary controls: Control plane and ingress load balancer exposure can be restricted and combined with firewall source controls to reduce public attack surface.
RKE2 Security Posture
- CNCF-conformant distribution option: RKE2 is supported as a first-class Kubernetes distribution choice in this module.
- Compliance-oriented operation: RKE2 is designed for hardened, regulated environments and supports CIS-focused deployment patterns.
- Certification visibility: For current security certifications/compliance mappings, reference the upstream RKE2 documentation and release notes as authoritative sources.
Connecting to the cluster
View cluster details:
terraform output kubeconfig
terraform output -json kubeconfig | jq
Connect via SSH
ssh root@<control-plane-ip> -i /path/to/private_key -o StrictHostKeyChecking=no
Restrict SSH access by configuring firewall_ssh_source in your kube.tf (default is ["myipv4"]). For CI/CD runners, override it with your runner CIDRs. See SSH docs for dynamic IP handling.
Connect via Kube API
kubectl --kubeconfig clustername_kubeconfig.yaml get nodes
Or set it as your default:
export KUBECONFIG=/<path-to>/clustername_kubeconfig.yaml
Tip: If
create_kubeconfig = false, generate it manually:terraform output --raw kubeconfig > clustername_kubeconfig.yaml
CNI Options
Default is Flannel. Switch by setting cni_plugin to "calico" or "cilium".
Cilium Configuration
Customize via cilium_values with Cilium helm values.
| Feature | Variable |
|---|---|
| Full kube-proxy replacement | enable_kube_proxy = false |
| Hubble observability | cilium_hubble_enabled = true |
Access Hubble UI:
kubectl port-forward -n kube-system service/hubble-ui 12000:80
# or with Cilium CLI:
cilium hubble ui
Scaling
Manual Scaling
Adjust count in any nodepool and run terraform apply. Constraints:
- First control-plane nodepool minimum: 1
- Drain nodes before removing:
kubectl drain <node-name> - Only remove nodepools from the end of the list
- Rename nodepools only when count is 0
Advanced: Replace count with a nodes map for individual node control—see kube.tf.example.
Autoscaling
Enable with autoscaler_nodepools. Powered by Cluster Autoscaler.
⚠️ Autoscaled nodes use a snapshot from the initial control plane. Ensure disk sizes match. Longhorn storage should stay on static agent nodepools. Autoscaled Longhorn volumes require a write-capable Hetzner token in node user-data and leave detached volumes behind on scale-down.
Cluster Autoscaler will not scale down nodes that run pods with local storage unless explicitly configured to do so. For disposable local data, add --skip-nodes-with-local-storage=false to cluster_autoscaler_extra_args or annotate individual pods with cluster-autoscaler.kubernetes.io/safe-to-evict: "true".
High Availability
| Control Planes | Recommendation |
|---|---|
| 3+ (odd numbers) | Full HA with quorum maintenance |
| 2 | Disable auto OS upgrades, manual maintenance |
| 1 | Development only, disable auto upgrades |
See Rancher's HA documentation.
Dedicated Servers
Integrate Hetzner Robot servers via the dedicated server guide.
Adding Extras
Use Kustomize for additional deployments:
- Create a source folder (default:
extra-manifests) with yourkustomization.yaml.tpland manifests. - Configure one or more ordered sets with
user_kustomizations. - Each set supports template parameters, optional pre-commands, and post-commands.
- Sets are applied sequentially with
kubectl apply -k.
📚 Examples & Recipes
Repository examples:
examples/argocd— configure Kubernetes/Helm providers fromkubeconfig_dataand install ArgoCD.examples/tailscale-node-transport— opt-in Tailscale node transport for secure single-network clusters and private multinetwork scale-out.examples/cilium-gateway-api— Cilium Gateway API, Gateway, HTTPRoute, and cert-manager HTTP-01.examples/cilium-multinetwork— experimental Cilium-only public-overlay preview across multiple Hetzner Cloud Networks.examples/external-overlay-tailscale— user-owned Tailscale operator access withnode_connection_overrides.examples/external-overlay-cloudflare-access— user-managed Cloudflare Zero Trust Access/Tunnel boundary for kube API, SSH, Rancher, and ingress.examples/kustomization_user_deploy— ordereduser_kustomizationssets.examples/tls— basic Ingress TLS resources for Traefik and cert-manager.
Custom post-install actions (ArgoCD, etc.)
For CRD-dependent applications:
user_kustomizations = {
"1" = {
source_folder = "extra-manifests"
kustomize_parameters = {
target_namespace = "argocd"
}
pre_commands = ""
post_commands = <<-EOT
kubectl -n argocd wait --for condition=established --timeout=120s crd/appprojects.argoproj.io
kubectl -n argocd wait --for condition=established --timeout=120s crd/applications.argoproj.io
kubectl apply -f /var/user_kustomize/1/argocd-projects.yaml
kubectl apply -f /var/user_kustomize/1/argocd-application-argocd.yaml
EOT
}
}
Useful Cilium commands
# Status
kubectl -n kube-system exec --stdin --tty cilium-xxxx -- cilium status --verbose
# Monitor traffic
kubectl -n kube-system exec --stdin --tty cilium-xxxx -- cilium monitor
# List services
kubectl -n kube-system exec --stdin --tty cilium-xxxx -- cilium service list
Cilium Egress Gateway with Floating IPs
Control outgoing traffic with static IPs:
{
name = "egress",
server_type = "cx23",
location = "nbg1",
labels = ["node.kubernetes.io/role=egress"],
taints = ["node.kubernetes.io/role=egress:NoSchedule"],
floating_ip = true,
count = 1
}
Configure Cilium:
locals {
cluster_ipv4_cidr = "10.42.0.0/16"
}
cluster_ipv4_cidr = local.cluster_ipv4_cidr
enable_kube_proxy = false
cilium_values = <<-EOT
ipam:
mode: kubernetes
k8s:
requireIPv4PodCIDR: true
kubeProxyReplacement: true
routingMode: native
ipv4NativeRoutingCIDR: "10.0.0.0/8"
endpointRoutes:
enabled: true
loadBalancer:
acceleration: native
bpf:
masquerade: true
egressGateway:
enabled: true
MTU: 1450
EOT
Cilium Egress Gateway requires kube-proxy replacement, so keep `enable_kube_proxy = false` when enabling it.
# Optional: keep selected egress policies pinned to a Ready egress node automatically
cilium_egress_gateway_ha_enabled = true
Example policy:
apiVersion: cilium.io/v2
kind: CiliumEgressGatewayPolicy
metadata:
name: egress-sample
labels:
kube-hetzner.io/egress-ha: "true"
spec:
selectors:
- podSelector:
matchLabels:
org: empire
class: mediabot
io.kubernetes.pod.namespace: default
destinationCIDRs:
- "0.0.0.0/0"
excludedCIDRs:
- "10.0.0.0/8"
egressGateway:
nodeSelector:
matchLabels:
node.kubernetes.io/role: egress
egressIP: { FLOATING_IP }
Cilium Gateway API
Cilium can own Gateway API directly:
cni_plugin = "cilium"
enable_kube_proxy = false
cilium_gateway_api_enabled = true
When enabled, kube-hetzner installs the standard Gateway API CRDs for the
selected Cilium line, enables gatewayAPI.enabled in Cilium values, and enables
cert-manager Gateway API support. This is separate from Traefik's Kubernetes
Gateway provider. Choose one Gateway API controller per cluster; v3 rejects
enabling Cilium Gateway API and Traefik's Gateway provider at the same time.
Use examples/cilium-gateway-api for a working
GatewayClass/Gateway/HTTPRoute/cert-manager HTTP-01 starting point.
Embedded Registry Mirror
k3s and RKE2 can use their embedded Spegel registry mirror to share images between trusted cluster nodes:
embedded_registry_mirror = {
enabled = true
registries = ["docker.io", "registry.k8s.io", "ghcr.io", "quay.io"]
disable_default_endpoint = false
}
kube-hetzner sets embedded-registry: true on server nodes and merges empty
mirror entries into the effective registries.yaml. Existing
registries_config entries and endpoints are preserved.
This is opt-in because the mirror assumes equal node trust. Images pulled with credentials on one node may be shared with other nodes, and tags can be poisoned by a node that can place images in containerd. Use digest-pinned images for critical workloads. In Tailscale multinetwork clusters, advertised node-private routes are required so the mirror can reach peers across Network shards.
TLS with Cert-Manager (recommended)
Cert-Manager handles HA certificate management (Traefik CE is stateless).
- Configure your issuer
- Add annotations to Ingress:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt
spec:
tls:
- hosts:
- "*.example.com"
secretName: example-com-letsencrypt-tls
rules:
- host: "*.example.com"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-service
port:
number: 80
Full Traefik + Cert-Manager guide
Ingress-Nginx with HTTP challenge: Add
load_balancer_hostname = "cluster.example.org"to work around this known issue.
F5 NGINX Ingress Controller:
ingress_controller = "nginx"installs the Kubernetes ingress-nginx controller. To run the F5 controller, setingress_controller = "none"and install F5's chart separately.
Managing snapshots
Create (recommended):
export HCLOUD_TOKEN=<your-token>
for distro in k3s rke2; do
packer build -var "selinux_package_to_install=${distro}" ./packer-template/hcloud-leapmicro-snapshots.pkr.hcl
done
Create (legacy MicroOS):
export HCLOUD_TOKEN=<your-token>
packer build ./packer-template/hcloud-microos-snapshots.pkr.hcl
Delete:
hcloud image list
hcloud image delete <image-id>
Custom OS snapshots per nodepool
Override the default OS snapshot on any nodepool or individual node with os_snapshot_id:
agent_nodepools = [
{
name = "storage",
server_type = "cx33",
location = "nbg1",
labels = ["node.kubernetes.io/server-usage=storage"],
taints = [],
count = 1
os_snapshot_id = "348644983" # Custom snapshot with LVM partitions
},
]
Per-node override (in a nodes map):
nodes = {
"0" : { os_snapshot_id = "348644983" },
"1" : {}, # uses nodepool or global default
}
Caution: You are responsible for ensuring the snapshot ID matches the correct
ostype (leapmicro/microos) and node architecture (x86 forcx*/cpx*servers, ARM forcax*servers). A mismatched snapshot will cause provisioning failures.
When not set, the module automatically selects the most recent snapshot matching the node's os and architecture.
Single-node development cluster
Set automatically_upgrade_os = false (attached volumes don't handle auto-reboots well).
Uses k3s service load balancer instead of external LB. Ports 80 & 443 open automatically.
Terraform Cloud deployment
- Create a Leap Micro snapshot in your project first (or MicroOS if you explicitly use it)
- Configure SSH keys as Terraform Cloud variables (mark private key as sensitive):
ssh_public_key = var.ssh_public_key
ssh_private_key = var.ssh_private_key
Password-protected keys: Requires
localexecution mode with your own agent.
HelmChartConfig customization
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: rancher
namespace: kube-system
spec:
valuesContent: |-
# Your values.yaml customizations here
Works for all add-ons: Longhorn, Cert-manager, Traefik, etc.
Encryption at rest (HCloud CSI)
Create secret:
apiVersion: v1
kind: Secret
metadata:
name: encryption-secret
namespace: kube-system
stringData:
encryption-passphrase: foobar
Create storage class:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: hcloud-volumes-encrypted
provisioner: csi.hetzner.cloud
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
parameters:
csi.storage.k8s.io/node-publish-secret-name: encryption-secret
csi.storage.k8s.io/node-publish-secret-namespace: kube-system
Encryption at rest (Longhorn)
Create secret:
apiVersion: v1
kind: Secret
metadata:
name: longhorn-crypto
namespace: longhorn-system
stringData:
CRYPTO_KEY_VALUE: "your-encryption-key"
CRYPTO_KEY_PROVIDER: "secret"
CRYPTO_KEY_CIPHER: "aes-xts-plain64"
CRYPTO_KEY_HASH: "sha256"
CRYPTO_KEY_SIZE: "256"
CRYPTO_PBKDF: "argon2i"
Create storage class:
kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
name: longhorn-crypto-global
provisioner: driver.longhorn.io
allowVolumeExpansion: true
parameters:
nodeSelector: "node-storage"
numberOfReplicas: "1"
staleReplicaTimeout: "2880"
fromBackup: ""
fsType: ext4
encrypted: "true"
csi.storage.k8s.io/provisioner-secret-name: "longhorn-crypto"
csi.storage.k8s.io/provisioner-secret-namespace: "longhorn-system"
csi.storage.k8s.io/node-publish-secret-name: "longhorn-crypto"
csi.storage.k8s.io/node-publish-secret-namespace: "longhorn-system"
csi.storage.k8s.io/node-stage-secret-name: "longhorn-crypto"
csi.storage.k8s.io/node-stage-secret-namespace: "longhorn-system"
Namespace-based architecture assignment
Enable admission controllers:
control_plane_exec_args = "--kube-apiserver-arg enable-admission-plugins=PodTolerationRestriction,PodNodeSelector"
Assign namespace to architecture:
apiVersion: v1
kind: Namespace
metadata:
annotations:
scheduler.alpha.kubernetes.io/node-selector: kubernetes.io/arch=amd64
name: this-runs-on-amd64
With tolerations:
apiVersion: v1
kind: Namespace
metadata:
annotations:
scheduler.alpha.kubernetes.io/node-selector: kubernetes.io/arch=arm64
scheduler.alpha.kubernetes.io/defaultTolerations: '[{ "operator" : "Equal", "effect" : "NoSchedule", "key" : "workload-type", "value" : "machine-learning" }]'
name: this-runs-on-arm64
Backup and restore cluster (etcd S3)
Setup backup:
- Configure
etcd_s3_backupin kube.tf - Add cluster_token output:
output "cluster_token" {
value = module.kube-hetzner.cluster_token
sensitive = true
}
Restore:
- Add restoration config to kube.tf:
locals {
cluster_token = var.cluster_token
etcd_version = "v3.5.9"
etcd_snapshot_name = "name-of-the-snapshot"
etcd_s3_endpoint = "your-s3-endpoint"
etcd_s3_bucket = "your-s3-bucket"
etcd_s3_access_key = "your-s3-access-key"
etcd_s3_secret_key = var.etcd_s3_secret_key
}
variable "cluster_token" {
sensitive = true
type = string
}
variable "etcd_s3_secret_key" {
sensitive = true
type = string
}
module "kube-hetzner" {
cluster_token = local.cluster_token
postinstall_exec = compact([
(
local.etcd_snapshot_name == "" ? "" :
<<-EOF
export CLUSTERINIT=$(cat /etc/rancher/k3s/config.yaml | grep -i '"cluster-init": true')
if [ -n "$CLUSTERINIT" ]; then
k3s server \
--cluster-reset \
--etcd-s3 \
--cluster-reset-restore-path=${local.etcd_snapshot_name} \
--etcd-s3-endpoint=${local.etcd_s3_endpoint} \
--etcd-s3-bucket=${local.etcd_s3_bucket} \
--etcd-s3-access-key=${local.etcd_s3_access_key} \
--etcd-s3-secret-key=${local.etcd_s3_secret_key}
mv /etc/rancher/k3s/k3s.yaml /etc/rancher/k3s/k3s.backup.yaml
ETCD_VER=${local.etcd_version}
case "$(uname -m)" in
aarch64) ETCD_ARCH="arm64" ;;
x86_64) ETCD_ARCH="amd64" ;;
esac;
DOWNLOAD_URL=https://github.com/etcd-io/etcd/releases/download
curl -L $DOWNLOAD_URL/$ETCD_VER/etcd-$ETCD_VER-linux-$ETCD_ARCH.tar.gz -o /tmp/etcd-$ETCD_VER-linux-$ETCD_ARCH.tar.gz
tar xzvf /tmp/etcd-$ETCD_VER-linux-$ETCD_ARCH.tar.gz -C /usr/local/bin --strip-components=1
nohup etcd --data-dir /var/lib/rancher/k3s/server/db/etcd &
echo $! > save_pid.txt
etcdctl del /registry/services/specs/traefik/traefik
etcdctl del /registry/services/endpoints/traefik/traefik
OLD_NODES=$(etcdctl get "" --prefix --keys-only | grep /registry/minions/ | cut -c 19-)
for NODE in $OLD_NODES; do
for KEY in $(etcdctl get "" --prefix --keys-only | grep $NODE); do
etcdctl del $KEY
done
done
kill -9 `cat save_pid.txt`
rm save_pid.txt
fi
EOF
)
])
}
- Set environment variables:
export TF_VAR_cluster_token="..."
export TF_VAR_etcd_s3_secret_key="..."
- Run
terraform apply
Pre-constructed private network (proxies)
resource "hcloud_network" "k3s_proxied" {
name = "k3s-proxied"
ip_range = "10.0.0.0/8"
}
resource "hcloud_network_subnet" "k3s_proxy" {
network_id = hcloud_network.k3s_proxied.id
type = "cloud"
network_zone = "eu-central"
ip_range = "10.128.0.0/9"
}
resource "hcloud_server" "your_proxy_server" { ... }
resource "hcloud_server_network" "your_proxy_server" {
depends_on = [hcloud_server.your_proxy_server]
server_id = hcloud_server.your_proxy_server.id
network_id = hcloud_network.k3s_proxied.id
ip = "10.128.0.1"
}
module "kube-hetzner" {
existing_network = { id = hcloud_network.k3s_proxied.id } # Note: object required!
network_ipv4_cidr = "10.0.0.0/9"
additional_kubernetes_install_environment = {
"http_proxy" : "http://10.128.0.1:3128",
"HTTP_PROXY" : "http://10.128.0.1:3128",
"HTTPS_PROXY" : "http://10.128.0.1:3128",
"CONTAINERD_HTTP_PROXY" : "http://10.128.0.1:3128",
"CONTAINERD_HTTPS_PROXY" : "http://10.128.0.1:3128",
"NO_PROXY" : "127.0.0.0/8,10.0.0.0/8,",
}
}
Tailscale node transport
Tailscale node transport is useful even before a cluster outgrows one Hetzner
Network. In a normal single-network cluster it gives Terraform, kubeconfig, and
operator SSH a private Tailnet path, so you can close public Kubernetes API and
SSH firewall rules without introducing a separate bastion workflow. Kubernetes
still keeps Hetzner private node IPs, so Hetzner CCM, CSI, and Load Balancers
continue to see provider-owned addresses instead of Tailnet 100.64.0.0/10
addresses.
For clusters that need more than one Hetzner Cloud Network, the same transport
becomes the production v3 scale-out path. Hetzner Networks still cap attached
resources per Network and do not route separate Networks together. Tailscale
fills that gap by advertising each node's own Hetzner private /32 route into
the Tailnet and accepting those routes on every node.
Tailscale mode does not require exposing Kubernetes itself to the web. The recommended large-cluster shape keeps public Kubernetes API and SSH firewall rules closed, disables managed public ingress unless you explicitly need it, and uses the Tailnet for operator/API/node transport. Nodes may still have public IPv4/IPv6 enabled so they can bootstrap Tailscale and form direct WireGuard paths; the public firewall opens Tailscale UDP/41641, not Kubernetes API, SSH, or HTTP/S. A truly no-public-IP multinetwork topology needs private egress and externally managed Tailscale bootstrap for every external Network; the module NAT router covers only the primary kube-hetzner Network.
Minimal secure single-network shape:
node_transport_mode = "tailscale"
tailscale_auth_key = var.tailscale_auth_key
tailscale_node_transport = {
# cloud_init brings Tailscale up before Terraform starts using SSH.
bootstrap_mode = "cloud_init"
magicdns_domain = "example-tailnet.ts.net"
auth = {
mode = "auth_key"
}
routing = {
# Single-network clusters already have Hetzner private reachability between
# nodes, so route approval is optional. Leave this true for multinetwork.
advertise_node_private_routes = false
}
}
# Tailscale mode deliberately rejects public world-open API/SSH defaults.
firewall_kube_api_source = null
firewall_ssh_source = null
# Every active Tailscale agent/autoscaler nodepool sets network_scope explicitly.
# Use "primary" when network_id is omitted/null.
# agent_nodepools = [{
# name = "agent", server_type = "cx23", location = "nbg1",
# labels = [], taints = [], count = 2, network_scope = "primary"
# }]
Multinetwork scale-out adds network_scope = "external" nodepools with
external network_id values and requires approved node-private routes. Set
network_scope explicitly in Tailscale mode so
Terraform can validate primary-vs-external Network intent during plan, even
when a network_id comes from an hcloud_network resource created in the same
root.
node_transport_mode = "tailscale"
# Use a reusable shared key, or role-specific keys when autoscaler nodes should
# use a reusable ephemeral key while static nodes use durable tagged keys.
tailscale_auth_key = var.tailscale_auth_key
# tailscale_control_plane_auth_key = var.tailscale_control_plane_auth_key
# tailscale_agent_auth_key = var.tailscale_agent_auth_key
# tailscale_autoscaler_auth_key = var.tailscale_autoscaler_auth_key
tailscale_node_transport = {
bootstrap_mode = "cloud_init" # required when autoscaler_nodepools are used
magicdns_domain = "example-tailnet.ts.net"
auth = {
mode = "auth_key"
# Tagged nodes are recommended for production ACLs and route auto-approval,
# but tags must be owned/permitted in your Tailnet policy before use.
# advertise_tags_control_plane = ["tag:kube-hetzner-control-plane"]
# advertise_tags_agent = ["tag:kube-hetzner-agent"]
# advertise_tags_autoscaler = ["tag:kube-hetzner-autoscaler"]
}
routing = {
advertise_node_private_routes = true
}
}
agent_nodepools = [
{
name = "agent-small-a"
server_type = "cx23"
location = "nbg1"
labels = []
taints = []
count = 50
# network_id omitted/null means the primary kube-hetzner network.
network_scope = "primary"
},
{
name = "agent-small-b"
server_type = "cx23"
location = "nbg1"
labels = []
taints = []
count = 50
network_id = 11959154 # existing external private network id
network_scope = "external"
},
]
autoscaler_nodepools = [
{
name = "autoscaled-a"
server_type = "cx23"
location = "nbg1"
min_nodes = 0
max_nodes = 50
network_scope = "primary"
},
{
name = "autoscaled-b"
server_type = "cx23"
location = "nbg1"
min_nodes = 0
max_nodes = 50
network_id = 11959154
network_scope = "external"
},
]
Large-scale reference layouts live in
examples/tailscale-node-transport:
large-scale-200.tf.exampleshows 200 total nodes across two Hetzner Networks while keeping each Network at exactly 100 attachments.massive-10000-nodes.tf.exampleshows the reference topology for 10,000 total nodes: 3 control planes, 7 static system agents, 90 autoscaled primary workers, and 99 external 100-node autoscaler shards. This is a quota/design reference, not a casual default; it requires Hetzner capacity approvals, Tailnet policy/device capacity, and production Kubernetes scale planning.
The important constraints are enforced during terraform plan:
node_transport_mode = "tailscale"is mutually exclusive withmultinetwork_mode = "cilium_public_overlay".- Control planes always stay on the primary kube-hetzner network and no longer
accept
network_id. - Static agents and autoscaler nodepools may use
network_idto spread across existing Hetzner private Networks. In Tailscale mode, every active static agent node, agent nodepool, and autoscaler nodepool must setnetwork_scope = "primary"ornetwork_scope = "external". - Control planes are not auto-attached to every external agent Network, avoiding Hetzner's 3-Networks-per-server limit.
- The module can advertise each node's Hetzner private
/32route through Tailscale, accepts Tailnet routes on nodes, and disables Tailscale subnet-route SNAT so Kubernetes/CNI traffic keeps the real Hetzner node source IP. - Single-network clusters may set
tailscale_node_transport.routing.advertise_node_private_routes = falseto avoid Tailnet route approvals. Any nodepool withnetwork_scope = "external"requires the defaulttrue. - For multinetwork clusters, Tailnet ACLs must auto-approve node-private routes
for the users, groups, or node tags you use, or the cluster will wait for
manual route approval. Tags are optional in
auth_keymode, but they are the cleanest production ACL boundary oncetagOwnersandautoApproversare configured. - With
auth.mode = "auth_key", use a reusabletailscale_auth_keyfor one shared key, or role-specific keys (tailscale_control_plane_auth_key,tailscale_agent_auth_key,tailscale_autoscaler_auth_key). A single-use key only registers the first node. Prefer a reusable, pre-approved, tagged, ephemeral key for autoscaler nodes. - With
auth.mode = "oauth_client_secret", the module passes role-specific OAuth auth-key parameters: static nodes default to durable devices and autoscaler-created nodes default to ephemeral devices. - Tailscale mode rejects world-open
firewall_kube_api_sourceandfirewall_ssh_source; usenullfor no public API/SSH rule or restrict to explicit CIDRs. - Public module-managed control-plane Load Balancers are rejected in Tailscale mode. Private control-plane Load Balancers remain available for single-network HA/API patterns; kubeconfig still defaults to the first control plane's Tailnet MagicDNS endpoint unless you set an explicit endpoint.
autoscaler_nodepoolsrequiretailscale_node_transport.bootstrap_mode = "cloud_init"because autoscaler-created nodes cannot be configured by Terraform remote-exec before joining.- The module NAT router can be combined with Tailscale only for
single-primary-network private egress. It does not provide egress for
external Hetzner Networks, so multinetwork Tailscale nodepools need their own
public IPv4/IPv6 egress. Do not set
nat_routerfor external-network Tailscale topologies in this release. - Managed Hetzner private Load Balancers work for single-primary-network
Tailscale clusters. They still cannot span external nodepool Networks; when
using
network_idscale-out, use public LB targets, Klipper, no/custom ingress, or an external load balancer.
The older multinetwork_mode = "cilium_public_overlay" path remains as a
gated lab preview for Cilium-only public transport experiments. Prefer
Tailscale node transport for real private multinetwork clusters.
Placement groups
Assign nodepools to placement groups:
agent_nodepools = [
{
...
placement_group = "special"
},
]
Legacy compatibility:
placement_group_index = 1
Count-based nodepools without an explicit placement_group are automatically
sharded into spread groups of 10 servers. Hetzner projects also cap placement
groups at 50 total, so very large static clusters must either disable placement
groups, split across projects/clusters, or use autoscaler nodepools for burst
capacity. kube-hetzner does not currently assign Hetzner Placement Groups to
autoscaler-created nodes. If you set an explicit placement_group, split
groups manually:
agent_nodepools = [
{
nodes = {
"0" : { placement_group = "pg-1" },
"30" : { placement_group = "pg-2" },
}
},
]
Disable globally: enable_placement_groups = false
Migrating from count to map-based nodes
Set append_index_to_node_name = false to avoid node replacement:
agent_nodepools = [
{
name = "agent-large",
server_type = "cx33",
location = "nbg1",
labels = [],
taints = [],
nodes = {
"0" : {
append_index_to_node_name = false,
labels = ["my.extra.label=special"],
placement_group = "agent-large-pg-1",
},
"1" : {
append_index_to_node_name = false,
server_type = "cx43",
labels = ["my.extra.label=slightlybiggernode"],
placement_group = "agent-large-pg-2",
},
}
},
]
Delete protection
Protect resources from accidental deletion via Hetzner Console/API:
enable_delete_protection = {
floating_ip = true
load_balancer = true
volume = true
}
Note: Terraform can still delete resources (provider lifts the lock).
Private-only cluster (WireGuard)
Requirements:
- Pre-configured network
- NAT gateway with public IP (Hetzner guide)
- WireGuard VPN access (Hetzner guide)
- Route
0.0.0.0/0through NAT gateway
Configuration:
existing_network = { id = 1234567 }
network_ipv4_cidr = "10.0.0.0/9"
# In all nodepools:
enable_public_ipv4 = false
enable_public_ipv6 = false
# For autoscaler:
autoscaler_enable_public_ipv4 = false
autoscaler_enable_public_ipv6 = false
# Optional private LB:
control_plane_load_balancer_enable_public_network = false
Private-only cluster (NAT Router)
Fully private setup with:
- Egress: Single NAT router IP
- SSH: Through bastion (NAT router)
- Control plane: Through LB or NAT router port forwarding
- Ingress: Through agents LB only
enable_control_plane_load_balancer = true
nat_router = {
server_type = "cax21"
location = "nbg1"
}
# Optional: use the router's private IP for SSH bastion traffic when the
# operator already reaches the private network through Tailscale/WireGuard/etc.
# use_private_nat_router_bastion = true
August 11, 2025: Hetzner removed legacy Router DHCP option. This module now automatically persists routes via the virtual gateway.
External user-owned overlays (Tailscale/ZeroTier/WireGuard/WARP)
Use node_transport_mode = "tailscale" when Tailscale should be the official
Kubernetes node transport for a single-network or multinetwork cluster. This
external-overlay pattern is different: it is for operator access, custom
control-plane endpoints, or post-bootstrap Tailscale Kubernetes Operator
features that you manage outside kube-hetzner.
There is still no broad enable_tailscale switch. kube-hetzner manages only
the narrow node-transport contract above. It does not manage tailnet ACLs,
route approvals, Tailscale Services, workload ingress/egress policy, or the
Tailscale Kubernetes Operator lifecycle. Use your overlay setup in an outer
module or out-of-band bootstrap, then pass resulting endpoints back into
kube-hetzner.
Cloudflare Zero Trust is documented separately because it is usually an Access/Tunnel edge rather than a node-to-node transport. Do not use Cloudflare Mesh/WARP as an officially supported kube-hetzner node transport in v3; use Tailscale for that contract.
The supported kube-hetzner primitives are:
preinstall_exec/postinstall_execfor user-owned bootstrap hooks.node_connection_overridesfor Terraform SSH/provisioners over Tailnet IPs.control_plane_endpointfor a stable external kube API endpoint.use_private_nat_router_bastionwhen a Tailscale/WireGuard/WARP path already reaches the private network.firewall_ssh_source/firewall_kube_api_sourcetightening after overlay access is proven.
# Bootstrap overlay client on each node (example commands only).
# Avoid long-lived auth keys here; command strings and cloud-init user-data can
# be visible in Terraform state/provider state or instance logs. Prefer an
# external bootstrap or short-lived, one-use preauth keys that are immediately
# rotated/revoked.
preinstall_exec = [
"curl -fsSL https://tailscale.com/install.sh | sh",
# "tailscale up --auth-key=${var.tailscale_auth_key} --ssh --hostname=$(hostname)",
]
# After overlay IPs are known, route Terraform SSH through them
# Keys must match final node names (with cluster prefix if enabled)
node_connection_overrides = {
"k3s-control-plane" = "100.64.0.10"
"k3s-agent-0" = "100.64.0.11"
}
# Optional: use an external control-plane endpoint exposed through overlay
control_plane_endpoint = "https://cp.tailnet.example:6443"
Typical workflow:
- Apply once to bootstrap nodes and install/join overlay agents.
- Resolve overlay addresses and set
node_connection_overrides. - Apply again and optionally tighten
firewall_ssh_source/firewall_kube_api_source. - After Kubernetes is healthy, deploy the Tailscale Kubernetes Operator with
Helm, ArgoCD, or
user_kustomizationsif you want Tailscale Services, workload ingress/egress, subnet routers, or kube API proxying.
For cluster node transport, prefer
examples/tailscale-node-transport/README.md.
For user-owned Tailscale operator access, see
examples/external-overlay-tailscale/README.md.
For Cloudflare Access/Tunnel operator and app access, see
examples/external-overlay-cloudflare-access/README.md.
Cloudflare Zero Trust Access/Tunnel (external)
Cloudflare Zero Trust is useful in v3 as an operator and application access layer. Keep the cluster transport underneath simple:
- Use
node_transport_mode = "tailscale"for supported secure node transport, especially single-network hardening and multinetwork scale-out. - Use Cloudflare Access/Tunnel for the Kubernetes API, SSH, Rancher, Grafana, or ingress hostnames that you choose to publish through Cloudflare.
- Manage Cloudflare accounts, policies, tunnels, DNS records, WARP enrollment, and service tokens outside kube-hetzner.
This module deliberately does not add a Cloudflare provider, Cloudflare token
variables, Cloudflare Mesh bootstrap, or node_transport_mode = "cloudflare".
Cloudflare Mesh is still beta in Cloudflare's own docs, and using it as
Kubernetes node transport would create a large support surface that v3 does not
need.
For kubeconfig access through Cloudflare Access, prefer a local helper path such
as cloudflared access tcp or a WARP/private-route design owned outside this
module. Do not point control_plane_endpoint at an Access-protected hostname
unless every joining control-plane and agent node can reach and authenticate to
that endpoint; control_plane_endpoint is also used for node joins.
For SSH, use Cloudflare's own Access SSH patterns, or expose reachable overlay
addresses through node_connection_overrides only after you have proven that
Terraform can connect through them.
Full example:
examples/external-overlay-cloudflare-access/README.md.
Fix SELinux issues with udica
Create targeted SELinux profiles instead of weakening cluster-wide security:
Policy posture and rule provenance are documented in docs/selinux.md.
Troubleshooting note: When using large attached volumes (for example large Longhorn disks), first boot can hit cloud-init/systemd timeouts while SELinux relabeling completes. If you hit this repeatedly, a practical workaround is to disable SELinux only on the affected nodepool(s) instead of disabling it cluster-wide.
# Find container
crictl ps
# Generate inspection
crictl inspect <container-id> > container.json
# Create profile
udica -j container.json myapp --full-network-access
# Install module
semodule -i myapp.cil /usr/share/udica/templates/{base_container.cil,net_container.cil}
Apply to deployment:
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: my-container
securityContext:
seLinuxOptions:
type: myapp.process
Thanks @carolosf
🔍 Debugging
Quick Status Check
hcloud context create Kube-hetzner # First time only
hcloud server list # Check nodes
hcloud network describe k3s # Check network
hcloud loadbalancer describe k3s-traefik # Check LB
SSH Troubleshooting
ssh root@<control-plane-ip> -i /path/to/private_key -o StrictHostKeyChecking=no
# View k3s logs
journalctl -u k3s # Control plane
journalctl -u k3s-agent # Agent nodes
# Check config
cat /etc/rancher/k3s/config.yaml
# Check uptime
last reboot
uptime
K3s certificate expiry: kubectl works but controllers do not reconcile
If kubectl can still read and patch objects but rollouts never progress, check
whether K3s component certificates expired on the control-plane nodes. One
common symptom is a Deployment whose spec was accepted, but whose
observedGeneration never catches up:
kubectl get deploy <name> -o jsonpath='generation={.metadata.generation} observed={.status.observedGeneration} updated={.status.updatedReplicas} ready={.status.readyReplicas} replicas={.status.replicas}{"\n"}'
Other useful checks:
kubectl get events --sort-by=.lastTimestamp | grep CertificateExpirationWarning
ssh root@<control-plane-ip> -i /path/to/private_key -o IdentitiesOnly=yes
k3s certificate check --output table
journalctl -u k3s -n 100 --no-pager | grep -E 'certificate has expired|tls: bad certificate|leaderelection'
Typical log lines include tls: failed to verify certificate: x509: certificate has expired from leaderelection.go, or etcd peer messages such as
remote error: tls: bad certificate. In that state, the API server may still
answer some requests, while the scheduler/controller-manager/etcd leadership
path is unhealthy.
K3s renews expired or near-expiry leaf certificates on service startup. Restart
the control-plane nodes one at a time, wait for each node to return, and restart
the node used by your current kubeconfig endpoint last if possible. Then check
that k3s certificate check --output table no longer shows expired leaf
certificates. WARNING rows for certs that are near expiry can remain; EXPIRED
rows should be gone.
for host in <control-plane-ip-1> <control-plane-ip-2> <control-plane-ip-3>; do
ssh root@"${host}" -i /path/to/private_key -o IdentitiesOnly=yes \
'systemctl restart k3s'
ssh root@"${host}" -i /path/to/private_key -o IdentitiesOnly=yes \
'systemctl is-active k3s && k3s certificate check --output table | grep -E "EXPIRED|WARNING" || true'
done
If automatic renewal on restart is not enough, use the
K3s manual rotation flow on each server
(systemctl stop k3s, k3s certificate rotate, systemctl start k3s). Rotate
servers first, then agents. After the controller manager observes the pending
Deployment generation, rerun the rollout check:
kubectl rollout status deploy/<name> --timeout=300s
kubectl get pods -l app=<label> -o wide
💣 Takedown
The recommended teardown path is scripts/destroy.sh: it runs terraform/tofu destroy with any extra args you pass, auto-retries the known benign ingress LB detach race documented in plans/011, and then prints a read-only hcloud orphan report. If state is already wrecked or resources are stuck outside Terraform, use scripts/cleanup.sh as the forceful fallback; it keeps a dry-run prompt by default.
tmp_script=$(mktemp) && curl -sSL -o "${tmp_script}" https://raw.githubusercontent.com/kube-hetzner/terraform-hcloud-kube-hetzner/master/scripts/destroy.sh && chmod +x "${tmp_script}" && "${tmp_script}" && rm "${tmp_script}"
Forceful cleanup fallback:
tmp_script=$(mktemp) && curl -sSL -o "${tmp_script}" https://raw.githubusercontent.com/kube-hetzner/terraform-hcloud-kube-hetzner/master/scripts/cleanup.sh && chmod +x "${tmp_script}" && "${tmp_script}" && rm "${tmp_script}"
⚠️ This deletes everything including volumes. Dry-run option available.
🤝 Contributing & Community
Contributing
Help wanted! Consider asking Hetzner to add MicroOS as a default image (not just ISO) at get.opensuse.org/microos. More requests = faster deployments for everyone!
Development Workflow
- Fork the project
- Create your branch:
git checkout -b AmazingFeature - Point your kube.tf
sourceto local clone - Useful commands:
sh ../kube-hetzner/scripts/cleanup.sh for distro in k3s rke2; do packer build -var "selinux_package_to_install=${distro}" ../kube-hetzner/packer-template/hcloud-leapmicro-snapshots.pkr.hcl; done # (legacy) # packer build ../kube-hetzner/packer-template/hcloud-microos-snapshots.pkr.hcl - Update
kube.tf.exampleif needed - Commit:
git commit -m 'Add AmazingFeature' - Push:
git push origin AmazingFeature - Open PR targeting
stagingbranch
Support This Project
If Kube-Hetzner saves you time and money, please consider supporting its development:
Your sponsorship directly funds:
🐛 Bug fixes and issue response
🚀 New features and improvements
📚 Documentation maintenance
🔒 Security updates and best practices
Every contribution matters. Thank you for keeping this project alive! 🙏
Acknowledgements
- k-andy — The starting point for this project
- Best-README-Template — README inspiration
- Hetzner Cloud — Outstanding infrastructure and Terraform provider
- HashiCorp — The amazing Terraform framework
- Rancher — k3s, the heart of this project
- openSUSE — Leap Micro & MicroOS, next-level container OS
Thanks to Hetzner for supporting this project with cloud credits.
License
Released under the license in LICENSE.
Made with ❤️ by the Kube-Hetzner community