Kubernetes cluster setup

Golem Trust operates three Kubernetes clusters: one in Hetzner’s Helsinki region (hel1), one spanning Nuremberg and Falkenstein (nbg1/fsn1) in Germany, and a third in Finland. Ludmilla designed this layout so that no single Hetzner region failure could take down all customer workloads simultaneously. The Royal Bank of Ankh-Morpork insisted on at least two geographically separated environments; the Patrician’s Office insisted on a third. This runbook covers the kubeadm-based installation procedure used to build and rebuild clusters, from bare Hetzner instances to a fully joined, production-ready cluster.

Prerequisites

Each cluster requires:

  • Three CX32 control plane nodes (4 vCPU, 8 GB RAM) in separate Hetzner availability zones within the region

  • Ten CX42 worker nodes (8 vCPU, 16 GB RAM) as the initial pool; auto-scaling is configured between 5 and 20 nodes

  • A Hetzner load balancer fronting the kube-apiserver on port 6443

  • Private networking enabled across all nodes

  • The Hetzner CSI driver for persistent volume provisioning

All nodes should be running Ubuntu 22.04 LTS. Assign hostnames following the convention <region>-cp-01, <region>-cp-02, <region>-cp-03 for control plane nodes and <region>-worker-01 through <region>-worker-10 for workers.

Prepare all nodes

Run the following on every node (control plane and workers) before running kubeadm:

# Disable swap
swapoff -a
sed -i '/swap/d' /etc/fstab

# Load required kernel modules
cat <<EOF | tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
modprobe overlay
modprobe br_netfilter

# Sysctl settings required by Kubernetes
cat <<EOF | tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF
sysctl --system

# Install containerd
apt-get update
apt-get install -y containerd
mkdir -p /etc/containerd
containerd config default | tee /etc/containerd/config.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
systemctl restart containerd

# Install kubeadm, kubelet, kubectl
apt-get install -y apt-transport-https ca-certificates curl
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.29/deb/Release.key \
  | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.29/deb/ /' \
  | tee /etc/apt/sources.list.d/kubernetes.list
apt-get update
apt-get install -y kubelet kubeadm kubectl
apt-mark hold kubelet kubeadm kubectl

Initialise the first control plane node

Replace <LOAD_BALANCER_IP> with the IP address of the Hetzner load balancer created for this cluster. Replace <POD_CIDR> with the cluster-specific CIDR assigned by Ludmilla (see the CIDR allocation table in Confluence; CIDRs must not overlap between clusters for multi-cluster routing to work correctly).

kubeadm init \
  --control-plane-endpoint "<LOAD_BALANCER_IP>:6443" \
  --upload-certs \
  --pod-network-cidr=<POD_CIDR> \
  --apiserver-advertise-address=<THIS_NODE_PRIVATE_IP>

Save the output. It contains the join commands for additional control plane nodes and for worker nodes, along with the certificate key. The certificate key is valid for two hours; if joining takes longer, regenerate it with kubeadm init phase upload-certs --upload-certs.

Join the remaining control plane nodes

On each of <region>-cp-02 and <region>-cp-03:

kubeadm join <LOAD_BALANCER_IP>:6443 \
  --token <TOKEN> \
  --discovery-token-ca-cert-hash sha256:<HASH> \
  --control-plane \
  --certificate-key <CERT_KEY> \
  --apiserver-advertise-address=<THIS_NODE_PRIVATE_IP>

Join worker nodes

On each worker node, run the worker join command from the kubeadm init output:

kubeadm join <LOAD_BALANCER_IP>:6443 \
  --token <TOKEN> \
  --discovery-token-ca-cert-hash sha256:<HASH>

Label worker nodes after joining so that workloads schedule correctly and the cluster autoscaler can identify them:

kubectl label node <region>-worker-01 node-role.kubernetes.io/worker=worker

Distribute kubeconfig

The cluster kubeconfig lives in /etc/kubernetes/admin.conf on the first control plane node. Copy it to the operator workstation and merge it into ~/.kube/config. Rename the context to match the cluster name before merging to avoid collisions between the three clusters:

# On the control plane node
cat /etc/kubernetes/admin.conf

# On the operator workstation
KUBECONFIG=~/.kube/config:~/downloads/new-cluster.yaml \
  kubectl config view --flatten > ~/.kube/config.merged
mv ~/.kube/config.merged ~/.kube/config

Install the Hetzner CSI driver

The CSI driver requires a Hetzner API token scoped to the project. Store it as a secret in the kube-system namespace:

kubectl create secret generic hcloud \
  -n kube-system \
  --from-literal=token=<HETZNER_API_TOKEN>

kubectl apply -f https://raw.githubusercontent.com/hetznercloud/csi-driver/main/deploy/kubernetes/hcloud-csi.yml

Verify the driver is running and that the hcloud-volumes StorageClass was created:

kubectl get pods -n kube-system | grep hcloud
kubectl get storageclass

Verify cluster health

kubectl get nodes -o wide
kubectl get pods -A | grep -v Running | grep -v Completed
kubectl get componentstatuses

All control plane nodes should be Ready. Ponder’s standing instruction: if any node shows NotReady for more than three minutes after setup, check journalctl -u kubelet on that node before proceeding. Dr. Crucible keeps a dedicated channel in the team’s messaging system for cluster bootstrap failures.