Use this section when you need exact variable names, output formats, templates, or operational reference material.
This is the multi-page printable view of this section. Click here to print.
Reference
- 1: Variables Reference
- 2: Outputs Reference
- 3: Cloud-Init Templates
- 4: Ansible Playbooks Reference
- 5: OS Images
- 6: Project Structure
- 7: Cost Estimate
1 - Variables Reference
All configurable inputs for the OpenTofu infrastructure. Set these in terraform.tfvars.
Required Variables
| Variable | Type | Description |
|---|---|---|
hcloud_token | string | Hetzner Cloud API Token (sensitive) |
Cluster Configuration
| Variable | Type | Default | Description |
|---|---|---|---|
cluster_name | string | "k8s-cluster" | Name prefix for all resources |
location | string | "fsn1" | Hetzner Cloud location (fsn1, nbg1, hel1, ash, hil) |
Network Configuration
| Variable | Type | Default | Description |
|---|---|---|---|
network_ip_range | string | "10.0.0.0/8" | IP range for the private network |
subnet_ip_range | string | "10.0.0.0/24" | IP range for the subnet |
network_zone | string | "eu-central" | Network zone (eu-central, us-east, us-west) |
enable_public_ipv6 | bool | true | Enable public IPv6 for replica control nodes and worker nodes. The master always has public IPv6 (required for cloudflared). Setting to false air-gaps replicas and workers. |
Server Configuration
| Variable | Type | Default | Description |
|---|---|---|---|
server_image | string | "debian-13" | Server image to use |
master_control_node_type | string | "cx23" | Server type for the master control node (runs cloudflared) |
control_node_types | list(object({type, count})) | [] | Server types and counts for replica control nodes |
worker_node_types | list(object({type, count})) | [] | Server types and counts for worker nodes |
The cost-optimized x86 line is cx23, cx33, cx43, and cx53; the Arm
equivalents are cax11 through cax41. See
Cost Estimate for
specifications, current prices, and the constraints on the Arm line.
Node type examples
# Master-only (default)
master_control_node_type = "cx23"
control_node_types = []
worker_node_types = []
# 3 control nodes + 2 workers
control_node_types = [
{ type = "cx23", count = 2 },
]
worker_node_types = [
{ type = "cx23", count = 2 },
]
# Mixed worker types
worker_node_types = [
{ type = "cx23", count = 2 },
{ type = "cx33", count = 1 },
]
Authentication
| Variable | Type | Default | Description |
|---|---|---|---|
root_password | string | "ChangeMe123!" | Root password for emergency Web-Console access (sensitive) |
admin_user | string | "kubernetes-admin" | Admin user name for SSH access |
keyboard_layout | string | "de" | Keyboard layout for cloud-init |
SSH Keys
If left empty, new keys will be auto-generated and stored in the OpenTofu state. When using custom keys, manage private keys yourself.
| Variable | Type | Default | Description |
|---|---|---|---|
control_node_public_key | string | "" | Public SSH key for control nodes. Leave empty to auto-generate. |
worker_node_public_key | string | "" | Public SSH key for worker nodes. Leave empty to auto-generate. |
admin_node_public_key | string | "" | Public SSH key for admin node. Leave empty to auto-generate. |
ssh_key_prefix | string | "" | Prefix for SSH key filenames. Defaults to cluster_name if empty. |
Cloudflare Configuration
| Variable | Type | Default | Description |
|---|---|---|---|
cloudflare_tunnel_domain | string | "" | Domain for Cloudflare Tunnel SSH access (e.g., console.example.org) |
cloudflare_tunnel_token | string | "" | Cloudflare Tunnel token for automatic setup on master node (sensitive). Leave empty for manual setup. |
Admin Node
| Variable | Type | Default | Description |
|---|---|---|---|
enable_admin_node | bool | true | Enable a temporary admin node with public IPv6 for initial SSH access. Disable after Cloudflare Tunnel is configured. |
admin_node_type | string | "cx23" | Server type for admin node |
NAT64/DNS64
| Variable | Type | Default | Description |
|---|---|---|---|
enable_nat64 | bool | true | Enable NAT64/DNS64 for IPv4 reachability on IPv6-only nodes |
dns64_resolvers | list(string) | ["2a01:4f8:c2c:123f::1", "2a01:4f9:c010:3f02::1", "2a00:1098:2b::1"] | DNS64 resolver addresses (nat64.net: Nuremberg, Helsinki, Amsterdam) |
Kubernetes
| Variable | Type | Default | Description |
|---|---|---|---|
enable_k8s_prereqs | bool | true | Install Kubernetes prerequisites (container runtime, kubeadm, kubelet, kubectl) via cloud-init |
kubernetes_version | string | "1.32" | Kubernetes minor version for the pkgs.k8s.io apt source |
container_runtime | string | "containerd" | Container runtime for Kubernetes nodes: "containerd" or "cri-o" |
2 - Outputs Reference
All outputs exposed by the OpenTofu configuration. These are consumed by the setup scripts and can be queried manually.
Network Information
| Output | Description |
|---|---|
network_id | ID of the private network |
network_name | Name of the private network |
Cluster Metadata
| Output | Description |
|---|---|
cluster_name | Name of the cluster |
admin_user | Admin user name |
ssh_key_prefix | Prefix used for SSH key filenames |
Master Control Node
| Output | Description |
|---|---|
master_control_node_id | ID of the master control node |
master_control_node_name | Name of the master control node |
master_control_node_private_ip | Private IP of the master control node |
All Control Nodes (master + replicas)
| Output | Description |
|---|---|
control_node_count | Total number of control nodes (master + replicas) |
control_node_ids | IDs of all control nodes |
control_node_names | Names of all control nodes |
control_node_private_ips | Private IPs of all control nodes |
Worker Nodes
| Output | Description |
|---|---|
worker_node_count | Number of worker nodes |
worker_node_ids | IDs of worker nodes |
worker_node_names | Names of worker nodes |
worker_node_private_ips | Private IPs of worker nodes |
Admin Node
| Output | Description |
|---|---|
admin_node_id | ID of the admin node (null if disabled) |
admin_node_name | Name of the admin node (null if disabled) |
admin_node_ipv6 | Public IPv6 address of the admin node (null if disabled) |
admin_node_private_ip | Private IP of the admin node (null if disabled) |
enable_admin_node | Whether the admin node is enabled |
SSH Keys
These outputs are sensitive when auto-generated keys are used.
| Output | Sensitive | Description |
|---|---|---|
control_node_ssh_private_key | Yes | Private SSH key for control nodes (only if auto-generated) |
control_node_ssh_public_key | No | Public SSH key for control nodes |
worker_node_ssh_private_key | Yes | Private SSH key for worker nodes (only if auto-generated) |
worker_node_ssh_public_key | No | Public SSH key for worker nodes |
admin_node_ssh_private_key | Yes | Private SSH key for admin node (only if auto-generated) |
admin_node_ssh_public_key | No | Public SSH key for admin node |
using_custom_keys | No | Map showing which node roles use custom keys |
SSH Config
| Output | Description |
|---|---|
ssh_config_snippet | SSH config snippet for ~/.ssh/config. Generates Host entries for admin-node (ProxyJump), cloudflare tunnel (ProxyCommand), and all nodes. All entries include IdentitiesOnly yes. |
Feature Flags
| Output | Description |
|---|---|
cloudflare_tunnel_domain | Configured Cloudflare Tunnel domain |
cloudflare_tunnel_configured | Whether cloudflare_tunnel_token was set, i.e. whether cloud-init installed the tunnel automatically |
nat64_enabled | Whether NAT64/DNS64 is enabled |
k8s_prereqs_enabled | Whether Kubernetes prerequisites are installed via cloud-init |
Next Steps Banner
| Output | Description |
|---|---|
next_steps | Instructions banner displayed after tofu apply |
Querying outputs
# List all outputs
tofu output
# Get a specific output
tofu output master_control_node_private_ip
# Get a sensitive output (raw)
tofu output -raw control_node_ssh_private_key
# Export SSH key to file
tofu output -raw control_node_ssh_private_key > ~/.ssh/k8s-cluster_control-node_key
chmod 600 ~/.ssh/k8s-cluster_control-node_key
3 - Cloud-Init Templates
Cloud-init templates are located in cloud-init/ and rendered by OpenTofu via templatefile() in main.tf. They configure each server on first boot.
admin-node.yaml.tpl
Purpose: Minimal jump host for initial SSH access.
Used by: hcloud_server.admin_node
Template variables:
| Variable | Source |
|---|---|
ssh_public_key | local.admin_node_public_key |
root_password | var.root_password |
admin_user | var.admin_user |
keyboard_layout | var.keyboard_layout |
What it configures:
- Admin user with sudo NOPASSWD
- SSH hardening with
AllowTcpForwarding yes(needed for ProxyJump) - fail2ban for SSH protection
- UFW allowing public SSH (port 22 from anywhere)
control-node.yaml.tpl
Purpose: Kubernetes control plane node with optional Cloudflare Tunnel.
Used by: hcloud_server.master_control_node, hcloud_server.control_node_replica
Template variables:
| Variable | Source |
|---|---|
ssh_public_key | local.control_node_public_key |
root_password | var.root_password |
admin_user | var.admin_user |
keyboard_layout | var.keyboard_layout |
is_master | true for master, false for replicas |
enable_nat64 | var.enable_nat64 |
dns64_resolvers | var.dns64_resolvers |
enable_k8s_prereqs | var.enable_k8s_prereqs |
kubernetes_version | var.kubernetes_version |
container_runtime | var.container_runtime |
cloudflare_tunnel_token | var.cloudflare_tunnel_token (master), "" (replicas) |
What it configures:
- Admin user with sudo NOPASSWD and root password for emergency console access
- SSH hardening (key-only auth,
AllowTcpForwarding yes) - fail2ban for SSH protection
- UFW: SSH from internal network (
10.0.0.0/8) + localhost (for Cloudflare Tunnel)
Conditional sections:
is_master = true: Installscloudflared, allows SSH from localhost, creates/etc/cloudflared/config.ymlwithedge-ip-version: "6". Whencloudflare_tunnel_tokenis set, runscloudflared service install <token>to auto-configure the tunnel as a systemd service.is_master = false: Skips cloudflared installation (token always empty for replicas)enable_nat64 = true: Configures DNS64 resolvers in systemd-resolved, adds NAT64 route (64:ff9b::/96), creates networkd-dispatcher persistence scriptenable_k8s_prereqs = true: Installs the selected container runtime (containerdwith SystemdCgroup and sandbox image fix, orcri-ofrom OBS repo with kubelet socket configuration), kubeadm, kubelet, kubectl, loads kernel modules (overlay,br_netfilter), sets sysctl params, disables swap, opens kubelet (10250) + etcd (2379-2380) ports
worker-node.yaml.tpl
Purpose: Kubernetes worker node with restricted network access.
Used by: hcloud_server.worker_node
Template variables:
| Variable | Source |
|---|---|
ssh_public_key | local.worker_node_public_key |
root_password | var.root_password |
admin_user | var.admin_user |
keyboard_layout | var.keyboard_layout |
enable_nat64 | var.enable_nat64 |
dns64_resolvers | var.dns64_resolvers |
enable_k8s_prereqs | var.enable_k8s_prereqs |
kubernetes_version | var.kubernetes_version |
container_runtime | var.container_runtime |
What it configures:
- Admin user with sudo NOPASSWD and root password for emergency console access
- SSH hardening with
AllowTcpForwarding no(workers cannot be used as jump hosts) - fail2ban for SSH protection
- UFW: deny all by default, SSH from internal network only, outbound limited to DNS + HTTP/S
Conditional sections:
enable_nat64 = true: Uses DNS64 resolvers instead of Hetzner DNS, adds NAT64 prefix UFW ruleenable_k8s_prereqs = true: Same as control node prerequisites (runtime selected bycontainer_runtime), but without etcd ports
Template rendering
Templates are rendered in main.tf via templatefile():
user_data = templatefile("${path.module}/cloud-init/control-node.yaml.tpl", {
ssh_public_key = local.control_node_public_key
root_password = var.root_password
admin_user = var.admin_user
keyboard_layout = var.keyboard_layout
is_master = true
enable_nat64 = var.enable_nat64
dns64_resolvers = var.dns64_resolvers
enable_k8s_prereqs = var.enable_k8s_prereqs
kubernetes_version = var.kubernetes_version
container_runtime = var.container_runtime
cloudflare_tunnel_token = var.cloudflare_tunnel_token
})
4 - Ansible Playbooks Reference
All playbooks are in ansible/playbooks/ and target the k8s_cluster host group by default.
update-system.yml
Purpose: Run apt update && apt upgrade on all nodes.
Usage:
cd ansible
ansible-playbook playbooks/update-system.yml
ansible-playbook playbooks/update-system.yml --limit control_nodes
ansible-playbook playbooks/update-system.yml -e "reboot_after_update=true"
Variables:
| Variable | Default | Description |
|---|---|---|
reboot_after_update | false | Reboot after update if the system requires it |
reboot_timeout | 300 | Timeout (seconds) to wait for reboot |
Tasks:
- Update apt cache
- Upgrade all packages (dist-upgrade with autoremove)
- Check if reboot is required (
/var/run/reboot-required) - Reboot if required and enabled
- Wait for system to come back online
security-hardening.yml
Purpose: Apply security measures on all nodes.
Usage:
cd ansible
ansible-playbook playbooks/security-hardening.yml
Variables:
| Variable | Default | Description |
|---|---|---|
enable_unattended_upgrades | true | Install and configure unattended-upgrades |
configure_fail2ban | true | Verify fail2ban is running |
Tasks:
- Install and configure unattended-upgrades (security updates only)
- Enable automatic update checks (daily)
- Verify fail2ban is running and monitoring SSH
- Set secure permissions on
/etc/shadow - Disable core dumps
- Apply sysctl hardening (reverse path filtering, source route rejection, ASLR)
configure-nat64.yml
Purpose: Configure DNS64 resolvers and NAT64 routing on already-running nodes. Cloud-init only runs at first boot – use this playbook for existing nodes or to reconfigure.
Usage:
cd ansible
ansible-playbook playbooks/configure-nat64.yml
ansible-playbook playbooks/configure-nat64.yml --limit control_nodes
ansible-playbook playbooks/configure-nat64.yml -e '{"dns64_resolvers":["2a01:4f8:c2c:123f::1"]}'
Variables:
| Variable | Default | Description |
|---|---|---|
dns64_resolvers | ["2a01:4f8:c2c:123f::1", "2a01:4f9:c010:3f02::1", "2a00:1098:2b::1"] | DNS64 resolver addresses |
nat64_prefix | "64:ff9b::/96" | NAT64 prefix |
Tasks:
- Configure DNS64 resolvers in systemd-resolved
- Add NAT64 route via default IPv6 gateway
- Create networkd-dispatcher script for persistent route
- Update UFW rules on worker nodes (remove Hetzner DNS, add DNS64 + NAT64 rules)
- Verify DNS64 resolution and NAT64 connectivity
prepare-k8s-nodes.yml
Purpose: Install container runtime (containerd or CRI-O), kubeadm, kubelet, and kubectl on already-running nodes. Cloud-init only runs at first boot – use this playbook for existing nodes.
Usage:
cd ansible
ansible-playbook playbooks/prepare-k8s-nodes.yml
ansible-playbook playbooks/prepare-k8s-nodes.yml --limit control_nodes
ansible-playbook playbooks/prepare-k8s-nodes.yml -e "kubernetes_version=1.32"
ansible-playbook playbooks/prepare-k8s-nodes.yml -e "container_runtime=cri-o"
Variables:
| Variable | Default | Description |
|---|---|---|
kubernetes_version | "1.32" | Kubernetes minor version for apt repo |
container_runtime | "containerd" | Container runtime: "containerd" or "cri-o" |
Tasks:
- Load kernel modules (
overlay,br_netfilter) - Set sysctl parameters (
bridge-nf-call-iptables,ip_forward) - Disable swap
- Install and configure container runtime (containerd with SystemdCgroup, or CRI-O from OBS repo)
- Add Kubernetes apt repository
- Install kubelet, kubeadm, kubectl (held at current version)
- Open UFW ports on control nodes (kubelet 10250, etcd 2379-2380)
- Verify kubeadm version and container runtime status
Ansible configuration
The ansible.cfg file configures:
- Remote user:
kubernetes-admin - Host key checking: disabled (nodes are ephemeral)
- Privilege escalation: sudo NOPASSWD
- SSH args: ForwardAgent, ControlMaster, ControlPersist for fast connections
- Pipelining: enabled for performance
5 - OS Images
Hetzner Cloud offers the following Debian/Ubuntu images:
| Image | Name | Recommendation |
|---|---|---|
debian-13 | Debian 13 Trixie | Recommended for K8s |
debian-12 | Debian 12 Bookworm | Stable, well-proven |
ubuntu-24.04 | Ubuntu 24.04 LTS | Good for K8s |
ubuntu-22.04 | Ubuntu 22.04 LTS | Well-proven |
Why Debian 13?
- Stability: long support cycles
- Compatibility: the
pkgs.k8s.ioapt repository used for kubeadm, kubelet, and kubectl ships Debian packages - Small footprint: leaner than Ubuntu, but not as small as Alpine
- No glibc/musl issues: unlike Alpine, no compatibility problems
Hetzner Cloud doesn’t provide dedicated “slim” or “minimal” variants. The standard images are fairly compact already.
6 - Project Structure
kubeclaw/
├── main.tf # Infrastructure (providers, network, firewalls, servers)
├── variables.tf # All configurable inputs
├── outputs.tf # IPs, SSH keys, ssh_config_snippet, next-steps banner
├── terraform.tfvars.example # Example configuration
├── hugo.yaml # Hugo + Docsy configuration
├── package.json # Docsy asset dependencies
├── themes/docsy/ # Pinned Docsy theme submodule
├── aibox.toml # Dev Container source of truth (aibox apply)
├── aibox.lock # Pinned aibox toolchain versions
├── CLAUDE.md # Claude Code project instructions
├── README.md # Project overview (concise)
├── LICENSE # MIT License
├── .gitignore
├── .devcontainer/
│ ├── devcontainer.json # Generated by aibox; do not edit directly
│ ├── Dockerfile # Generated by aibox; do not edit directly
│ ├── docker-compose.yml # Generated by aibox; do not edit directly
│ ├── Dockerfile.local # Project layer: installs cloudflared
│ └── docker-compose.override.yml # Project override: writable SSH mount
├── cloud-init/
│ ├── admin-node.yaml.tpl # Admin node (temporary jump host with public IPv6)
│ ├── control-node.yaml.tpl # Control node (cloudflared on master, UFW, fail2ban, k8s prereqs)
│ └── worker-node.yaml.tpl # Worker node (isolated, outbound DNS/HTTP/S only, k8s prereqs)
├── scripts/
│ ├── setup-ssh.sh # Export SSH keys from tofu state, generate ~/.ssh/config
│ ├── ssh-agent-setup.sh # Fix SSH permissions, start ssh-agent, load keys
│ ├── generate-ansible-inventory.sh # Build ansible/inventory.ini from tofu state
│ ├── build-docs.sh # Build Hugo site locally
│ ├── serve-docs.sh # Preview Hugo site locally
│ └── deploy-docs.sh # Build and deploy Hugo to gh-pages
├── ansible/
│ ├── ansible.cfg # Ansible defaults (user, pipelining, SSH args)
│ ├── inventory.ini # Auto-generated inventory (do not hand-edit)
│ └── playbooks/
│ ├── update-system.yml # System updates with optional reboot
│ ├── security-hardening.yml # Unattended upgrades, fail2ban, sysctl hardening
│ ├── configure-nat64.yml # NAT64/DNS64 for IPv4 reachability on running nodes
│ └── prepare-k8s-nodes.yml # Kubernetes prerequisites (container runtime, kubeadm) on running nodes
├── assets/ # Project brand layer for the Hugo site
│ ├── icons/logo.svg # projectious mark used in the navbar
│ └── scss/ # Brand tokens and Docsy surface styles
├── layouts/ # Project template overrides (favicons, head hooks)
├── static/favicons/ # Brand favicon set
├── content/ # Hugo documentation source
│ ├── _index.md # Branded landing page
│ └── docs/ # Documentation section
│ ├── _index.md # Documentation overview
│ ├── quick-start.md # Combined prerequisites + deployment steps
│ ├── introduction/ # Concepts: architecture, security, DNS/NAT64
│ ├── guide/ # Ordered deployment path (dev container -> OpenClaw)
│ ├── how-to/ # Focused procedures, incl. the manual setup alternative
│ ├── reference/ # Variables, outputs, templates, playbooks, structure
│ ├── operations/ # Day-two: scaling, rotation, upgrades, troubleshooting
│ ├── roadmap/ # Planned work
│ └── contributing/ # Development setup and code of conduct
└── public/ # Generated site output (gitignored)
Key Files
Infrastructure (root level)
| File | Purpose |
|---|---|
main.tf | Core infrastructure: providers, SSH keys, network/subnet, firewalls, servers, cloud-init rendering |
variables.tf | All configurable inputs with types, defaults, and descriptions |
outputs.tf | Exposes IPs, SSH keys (sensitive), ssh_config_snippet, and next-steps banner |
terraform.tfvars.example | Reference configuration (actual .tfvars is gitignored) |
Cloud-Init Templates
| File | Purpose |
|---|---|
cloud-init/admin-node.yaml.tpl | Minimal jump host: admin user, SSH hardening, fail2ban, public SSH |
cloud-init/control-node.yaml.tpl | Control plane: cloudflared (master only), UFW, fail2ban, optional NAT64 + K8s prereqs |
cloud-init/worker-node.yaml.tpl | Worker: restrictive UFW, no TCP forwarding, optional NAT64 + K8s prereqs |
Scripts
| File | Purpose |
|---|---|
scripts/setup-ssh.sh | Export SSH keys, generate ~/.ssh/config with backup |
scripts/ssh-agent-setup.sh | Fix SSH permissions, start ssh-agent, load keys |
scripts/generate-ansible-inventory.sh | Build Ansible inventory from OpenTofu state |
scripts/build-docs.sh | Build the Hugo site locally |
scripts/serve-docs.sh | Preview the site locally |
scripts/deploy-docs.sh | Build Hugo and push to gh-pages |
Dev Container
The aibox configuration in aibox.toml is the source of truth for generated
Dev Container files. Run aibox apply after changing it; do not hand-edit
.devcontainer/Dockerfile, docker-compose.yml, or devcontainer.json.
Dockerfile.local and docker-compose.override.yml are project-owned
extensions and are safe to edit.
Ansible
| File | Purpose |
|---|---|
ansible/ansible.cfg | Defaults: remote user, host key checking, privilege escalation |
ansible/inventory.ini | Auto-generated inventory with control_nodes, worker_nodes, k8s_cluster groups |
Documentation site
| File | Purpose |
|---|---|
hugo.yaml | Hugo + Docsy configuration, menus, version list, and module mounts |
assets/scss/_variables_project.scss | projectious brand tokens mapped onto Bootstrap/Docsy variables |
assets/scss/_styles_project.scss | Brand surface styles: type scale, code theme, dark mode, chrome |
assets/icons/logo.svg | The projectious mark inlined into the navbar |
layouts/partials/favicons.html | Favicon set override (Docsy’s default references assets this project does not ship) |
layouts/partials/hooks/head-end.html | Brand web-font loading |
static/favicons/ | Brand favicon and touch-icon assets |
7 - Cost Estimate
All figures are list prices in EUR excluding VAT, current as of the Hetzner price adjustment of 15 June 2026. Hetzner bills hourly; the monthly figure is the cap you will not exceed. Always check Hetzner Cloud pricing before committing – prices changed several times during 2026.
Server types
KubeClaw defaults to the cost-optimized CX line (shared vCPU, x86). The
CAX line offers the same resources on Ampere Arm cores, but is only
available in the German and Finnish locations (fsn1, nbg1, hel1) and
requires arm64 container images throughout the cluster.
| Type | vCPU | RAM | NVMe | Monthly (excl. IPv4) |
|---|---|---|---|---|
cx23 | 2 | 4 GB | 40 GB | €5.49 |
cx33 | 4 | 8 GB | 80 GB | €8.49 |
cx43 | 8 | 16 GB | 160 GB | €15.99 |
cx53 | 16 | 32 GB | 320 GB | €29.49 |
cax11 (Arm) | 2 | 4 GB | 40 GB | €5.99 |
cax21 (Arm) | 4 | 8 GB | 80 GB | €10.49 |
cax31 (Arm) | 8 | 16 GB | 160 GB | €20.99 |
cax41 (Arm) | 16 | 32 GB | 320 GB | €40.99 |
The higher-performance CPX (dedicated AMD share) and CCX (fully
dedicated) lines are considerably more expensive – cpx22 is €19.49/month and
ccx13 is €42.99/month – and are rarely worth it for a learning or
small-production cluster.
Additional costs
| Item | Price |
|---|---|
| Primary IPv4 address | €0.50 per server per month |
| Block storage volume | ~€0.0572 per GB per month |
| Private network | Free |
| Cloudflare Tunnel + Access | Free (up to 50 users) |
Example: 2-node cluster
| Component | Specification | Monthly Cost |
|---|---|---|
Master control node (cx23, IPv6-only) | 2 vCPU / 4 GB RAM | €5.49 |
Worker node (cx23, IPv6-only) | 2 vCPU / 4 GB RAM | €5.49 |
| Block volumes (10 GB each) | Hetzner CSI, 20 GB total | ~€1.14 |
| Private network | – | Free |
| Cloudflare Tunnel + Access | Up to 50 users | Free |
| Total | ~€12.12/month |
Scaling costs
Server cost only; add block volumes for any workload that needs persistent storage.
| Configuration | Nodes | Server cost |
|---|---|---|
| Master-only (dev/learning) | 1 | €5.49 |
| Master + 1 worker | 2 | €10.98 |
| 3 control + 2 workers (HA) | 5 | €27.45 |
Notes
- The admin node (
cx23, €5.49/month) is temporary. Setenable_admin_node = falseonce the Cloudflare Tunnel works, and the charge stops – see Quick Start. - Mixed server types are supported, so workers can be sized independently of the control plane. See Scale Up/Down.
- Snapshots and backups are billed separately and are not included above.