First Cluster
A detailed walkthrough of the cluster.lok8s.yaml configuration file.
Prerequisites
- lok8s CLI installed
- A Hetzner Cloud account with an API token
- An SSH key pair (Ed25519 recommended)
- A domain you control: the API endpoint and certificate names derive from it (set as
spec.cluster.domain;example.combelow is a placeholder)
Full configuration reference
Show the full cluster.lok8s.yaml
# clusters/example.com/cluster.lok8s.yaml
apiVersion: cluster.lok8s.dev/v1beta1
kind: KubeOne
metadata:
name: my-cluster
spec:
kubernetes:
version: "v1.35.5"
cluster:
# REQUIRED: a domain you control; used for ingress and cert names
domain: example.com
# default namespace for workloads
namespace: default
provider:
name: hetzner
# The machines, in Hetzner's own terms. Each `server` entry becomes one
# `hcloud server create`; its keys are hcloud flags. Put the same content
# in a separate file and point `configRef: hetzner.json` at it if you
# prefer a shorter spec.
config:
cluster_name: my-cluster
sshUser: root
sshPrivateKey: ~/.ssh/id_ed25519
sshPublicKey: ~/.ssh/id_ed25519.pub
ssh-key:
- name: my-cluster
public-key-from-file: ~/.ssh/id_ed25519.pub
network:
- name: my-cluster
# private network range
ip-range: 10.0.0.0/16
"#subnets":
- network-zone: eu-central
type: cloud
ip-range: 10.0.0.0/24
server:
# 3 control planes for HA; 1 is enough for dev
- name: cp-1
type: cx33
image: ubuntu-24.04
# fsn1, nbg1, or hel1
location: fsn1
ssh-key: [0]
network: 0
label: lok8s.dev/cluster=my-cluster,lok8s.dev/role=control-plane
- name: cp-2
type: cx33
image: ubuntu-24.04
location: fsn1
ssh-key: [0]
network: 0
label: lok8s.dev/cluster=my-cluster,lok8s.dev/role=control-plane
- name: cp-3
type: cx33
image: ubuntu-24.04
location: fsn1
ssh-key: [0]
network: 0
label: lok8s.dev/cluster=my-cluster,lok8s.dev/role=control-plane
- name: worker-1
type: cpx31
image: ubuntu-24.04
location: fsn1
ssh-key: [0]
network: 0
label: lok8s.dev/cluster=my-cluster,lok8s.dev/role=worker
- name: worker-2
type: cpx31
image: ubuntu-24.04
location: fsn1
ssh-key: [0]
network: 0
label: lok8s.dev/cluster=my-cluster,lok8s.dev/role=worker
# cluster-infra addons, applied in order
bootstrap:
# CNI (the default if omitted)
- cilium
# Hetzner cloud-controller-manager
- ccm
- cert-manager
- monitoring
kubehz:
# self (you run everything) | hosted (kubehz runs the control plane)
# | shared (a Space on a shared control plane, kind: Kubehz)
hosting: self
# none | registered | managed (Supporter+)
access: registered
# cronjob (read-only heartbeat) | operator (managed tier, live view)
agent: cronjob
apiUrl: https://api.kubehz.cloudKey sections
spec.kubernetes
Set the Kubernetes version. kubehz offers a curated list, each version pinned to a patch release: today v1.34.8 and v1.35.5 (the default). A version is withdrawn as soon as upstream stops patching its minor.
spec.provider
Who provides the machines. name: hetzner selects Hetzner Cloud, the one implemented provider. config (or configRef, a path to a JSON or YAML file next to the spec) is the provider descriptor: the SSH access lok8s uses (sshUser, sshPrivateKey, sshPublicKey), and one list per Hetzner resource (ssh-key, network, server, and optionally volume and load-balancer). Each entry becomes one hcloud <resource> create, and its keys are that command’s flags. A server’s ssh-key: [0] and network: 0 refer to entries of those lists by index.
The lok8s.dev/role label decides what a server becomes: control-plane or worker. Three control planes give you HA; one is enough for development. type is a Hetzner server type, location the datacenter.
Bare metal. A Hetzner dedicated server joins the same descriptor as a #cloud.root entry (a Robot server that lok8s reimages and enrolls). See the lok8s provider reference for the shape.
Deprecated shape. Older specs carried spec.ssh, spec.controlPlane and spec.workers. lok8s still reads them as fallbacks, but they should not be used in new specs: the provider descriptor owns SSH access and node topology.
spec.bootstrap
An ordered list of cluster-infra addons (CNI, CCM, cert-manager, monitoring, …) applied at provision time, before any workloads land. Each entry is applied and waited on before the next. Bare names resolve to lok8s framework addons; ./path entries point at your own kustomize directories. If omitted, it defaults to cilium: every cluster needs a CNI. See the lok8s addons guide for the full list.
spec.kubehz
Optional kubehz platform integration. hosting says who runs the control plane: self (you, on your own account), hosted (kubehz, see Hosted) or shared (a Space on a control plane kubehz shares between customers; this one needs kind: Kubehz). access says what kubehz sees: none, registered (read-only health) or managed (adds the management features: healing policies, capacity watches, desired-state management; requires a Supporter subscription or higher). Acting is pull-based: the in-cluster agent fetches desired state from the platform and applies it with your cluster’s own credentials; kubehz never holds inbound access.
The other keys are optional:
| Key | Meaning |
|---|---|
agent | Which in-cluster agent owns the heartbeat: cronjob (the read-only default) or operator (the managed tier’s live view). |
apiUrl | The kubehz API, HTTPS only. Required whenever access is not none, and for hosted and shared. |
connectHcloudToken | true hands the platform your HCLOUD_TOKEN at registration, over HTTPS only, so a hosted cluster can provision worker pools. Off by default. |
upgrades | channel: none / patch / minor (how far the platform may upgrade unasked, default patch) and defer: window / immediate. |
maintenanceWindow | When platform-driven work may run: daysOfWeek, startTime, durationMinutes, timezone, and exclusions (dates or ranges that freeze everything). |
space | hosting: shared only: the Space’s slug, display name, and the nodes to mint join tickets for on provision. |
See How it works for the full model and the trust boundary, and Registration to turn on visibility.
Save the file
lo keeps one folder per cluster under clusters/, named after the domain, and reads cluster.lok8s.yaml from there. Create the folder, save the file, then mark the domain active:
mkdir -p clusters/example.com
# paste the reference above into clusters/example.com/cluster.lok8s.yaml
lo use example.comlo use records the active domain in clusters/.active; --domain <domain> or DOMAIN_NAME override it for a single command.
Edit these before provisioning. The reference ships placeholders, not defaults:
| Field | Placeholder | What it must be |
|---|---|---|
metadata.name | my-cluster | your cluster’s name |
spec.cluster.domain and the folder | example.com | a domain you control (it is used for the API endpoint) |
the SSH paths in provider.config | ~/.ssh/id_ed25519[.pub] | your key’s real path, only if it is not the default Ed25519 name |
Give lok8s your Hetzner token
lo provision creates real, billable infrastructure in your Hetzner account and authenticates with the API token from the prerequisites. Export it in the same shell you are about to provision from. It is read from the environment, never from the config file, so it never lands in git:
export HCLOUD_TOKEN="<your-token>"Provision your cluster
Run this from your project directory, with the domain active:
lo provisionProvisioning typically takes 10–15 minutes. The kubeconfig is written to .kubeconfig/my-cluster.yaml in your project, named after metadata.name. Your ~/.kube/config is never touched.
The reference config above asks Hetzner for five servers (3 control plane and 2 workers), billed hourly at Hetzner’s published prices for those server types. Nothing here is one-way: lo destroy removes every server lok8s created, whenever you want.
Verify
kubectl still points at whatever context it used before, so select the new cluster explicitly; otherwise get nodes answers for your previous one:
export KUBECONFIG=.kubeconfig/my-cluster.yaml
kubectl get nodes
# (AGE trimmed — STATUS is what to check)
# NAME STATUS ROLES VERSION
# cp-1 Ready control-plane v1.35.5
# cp-2 Ready control-plane v1.35.5
# cp-3 Ready control-plane v1.35.5
# worker-1 Ready <none> v1.35.5
# worker-2 Ready <none> v1.35.5
kubectl get pods -A
# Lists ONLY pods that have not settled. On a healthy cluster it prints nothing:
kubectl get pods -A --field-selector=status.phase!=Running,status.phase!=Succeeded
# No resources foundEvery node should read Ready, and the VERSION column should match the Kubernetes version you configured above.
kubectl get pods -A prints every system pod, which is a lot to eyeball, so the second command inverts it: it shows only what has not reached Running or Completed. No resources found is the answer you want. Straight after provisioning give the CNI and the cloud-controller a minute to finish starting before treating anything listed there as a failure.
Next steps
- Dashboard setup: monitor your cluster
- Registration: connect to the kubehz dashboard
- KubeOne guide: advanced KubeOne configuration
Doc status
| Aspect | Detail |
|---|---|
| Last reviewed | 5 September 2026 |