This lab is a disposable two-node kubeadm cluster for CKS study. It is not the production k3s stack and it does not use Terraform. You create two Ubuntu VMs, install containerd and kubeadm v1.34 on both, initialize one control plane, install Cilium, join the worker, then copy the admin kubeconfig onto your workstation as a named context so you can reach the API without overwriting production.
Site values (VM names, IPs, SSH key path) live in homelabs-private/clusters/<cluster>/config/cluster.yaml. Public commands below use placeholders. Do not terraform apply inside kubernetes/terraform/ for these guests: that root owns the k3s VMs.
What you get#
| Role | VM name | Tags | Size |
|---|---|---|---|
| Control plane | cks-control-plane-01 | cks, kubeadm, control-plane | 2 vCPU, 8 GiB |
| Worker | cks-node-01 | cks, kubeadm, node | 2 vCPU, 8 GiB |
After kubeadm, the API listens on the control-plane IP at :6443 on the LAN only. There is no Cloudflare hostname and no Dex on this cluster.
Pod CIDR is 10.244.0.0/16. Do not let Cilium pick a 192.168.0.0/16 pool; that overlaps a typical home LAN. Keep kubeadm’s kube-proxy. Cilium is the CNI. Istio ambient is optional after both nodes are Ready.
You can type bootstrap commands in the Proxmox VM Console or over SSH. Do not paste them into the Proxmox node’s host Shell.
Prerequisites#
You need two Ubuntu guests that can reach each other on the LAN, Helm 3 (on the control plane or on the workstation after you merge kubeconfig), and unused IPv4 addresses recorded in cluster.yaml. Confirm those addresses are not k3s nodes, the API VIP, MetalLB pool members, or the NAS.
The guests in this lab match the same cloud-init template as k3s (9999). If you still need that template, follow Cloud-init. You can also build two Ubuntu VMs another way, as long as they have 2 vCPU, 8 GiB RAM, a virtio NIC on vmbr0, and static IPv4.
1. Create the VMs#
Provision cks-control-plane-01 and cks-node-01 yourself. There is no Terraform root for this lab.
The path that matches the rest of this page is a full clone of template 9999 in the Proxmox web UI onto NAS storage. Do not use a linked clone: the new disks would stay tied to template 9999 (a recreate of that template would break the lab), and the extra copy-on-write layer is a poor fit for etcd on NAS.
For each guest, starting from template 9999:
- Open the Proxmox web UI and select the template VM (ID 9999).
- Click Clone. Choose Full Clone. Name the guest
cks-control-plane-01orcks-node-01, and set Target Storage to the NAS datastore (notlocal-lvm). Pick the Proxmox node that should host the VM. - After the clone finishes, open the new VM. Under Hardware, set 2 CPU cores and 8192 MiB memory. Keep one virtio NIC on
vmbr0. - Under Cloud-Init, set the user, SSH public key, and a static IPv4 (
<control-plane-ip>/24or<worker-ip>/24) plus your LAN gateway. Do not leave both guests on DHCP if you need stable kubeadm advertise addresses. - Under Options, enable start on boot if you want that. Add tags
cks,kubeadm, and eithercontrol-planeornode. - Start the VM. Wait until cloud-init finishes (guest login works).
Repeat for the second VM. Log in on each guest and continue.
2. Prepare both nodes#
Run this section on both VMs.
Disable swap for this boot and in fstab:
sudo swapoff -a
sudo sed -i '/swap/s/^/#/' /etc/fstabLoad the kernel modules kubeadm expects, and persist them:
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilterEnable IPv4 forwarding and iptables for bridged traffic:
cat <<EOF | sudo 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
sudo sysctl --system3. Install containerd on both nodes#
Install containerd from Docker’s apt repository (not the older Ubuntu universe package), then turn on the systemd cgroup driver:
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list
sudo apt-get update
sudo apt-get install -y containerd.io
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl restart containerd
sudo systemctl enable containerdConfirm the socket exists: ls /run/containerd/containerd.sock.
4. Install kubeadm, kubelet, and kubectl 1.34 on both nodes#
Use the v1.34 package stream at pkgs.k8s.io. Hold the packages so a later apt upgrade does not skip a kubeadm minor.
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gpg
sudo mkdir -p -m 755 /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.34/deb/Release.key | sudo 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.34/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl
sudo systemctl enable --now kubeletkubelet will restart until kubeadm init or join gives it a config. That crash loop is expected.
Check the version on both nodes:
kubeadm version
kubectl version --clientYou want a 1.34.x client. Pin a patch with apt-cache madison kubeadm if you need a specific build for the exam window.
5. Initialize the control plane#
Run this only on cks-control-plane-01. Replace <control-plane-ip> with that VM’s address from cluster.yaml.
K8S_VER=$(kubeadm version -o short)
sudo kubeadm init \
--kubernetes-version="$K8S_VER" \
--apiserver-advertise-address=<control-plane-ip> \
--pod-network-cidr=10.244.0.0/16 \
--cri-socket unix:///run/containerd/containerd.sock$K8S_VER is the 1.34.x patch you installed. The advertise address must be the LAN IP of this VM, not 127.0.0.1.
When init finishes, install the admin kubeconfig for the guest user. You will copy this file to the workstation after the worker joins.
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown "$(id -u):$(id -g)" $HOME/.kube/config
kubectl get nodesThe control plane stays NotReady until Cilium is running.
6. Install Cilium#
On the control plane, using the kubeconfig kubeadm just wrote. Install Helm 3 if it is not already there, then pin Cilium Helm chart 1.18.12. Keep kube-proxy. Set cni.exclusive=false so Istio’s CNI can chain later. Do not install Calico.
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm repo add cilium https://helm.cilium.io/
helm repo update
helm install cilium cilium/cilium --version 1.18.12 \
--namespace kube-system \
--set kubeProxyReplacement=false \
--set cni.exclusive=false \
--set ipam.operator.clusterPoolIPv4PodCIDRList=10.244.0.0/16Wait until Cilium is up:
kubectl -n kube-system rollout status ds/cilium
kubectl get pods -A
kubectl get nodesThe control plane becomes Ready. The worker is still missing.
7. Join the worker#
On the control plane, print a fresh join command. Do not paste an old token from notes or git.
sudo kubeadm token create --print-join-commandOn cks-node-01, run that output with sudo. Add --cri-socket unix:///run/containerd/containerd.sock if kubeadm complains about multiple runtimes.
Back on the control plane:
kubectl get nodes -o wideBoth names should be Ready. Cilium must be Running on the new node as well (kubectl -n kube-system get pods -o wide).
8. Add a workstation kubeconfig context#
The lab API is LAN-only. Production kubectl uses a different context (OIDC on the k3s cluster). Merge the kubeadm admin kubeconfig into ~/.kube/config as a new context. Do not replace the production file. Do not commit the merged file.
On the workstation, back up the existing kubeconfig, then copy the guest file. Replace <ssh-user>, <cloud-init-key>, and <control-plane-ip> from cluster.yaml. Use the cluster name there as the context name (<dev-cluster-context>).
cp ~/.kube/config ~/.kube/config.bak
scp -i ~/.ssh/<cloud-init-key> <ssh-user>@<control-plane-ip>:.kube/config /tmp/cks-admin.conf
chmod 600 /tmp/cks-admin.conf
kubectl --kubeconfig /tmp/cks-admin.conf config rename-context kubernetes-admin@kubernetes <dev-cluster-context>Merge the two files. Unix shells join kubeconfig paths with :. Native Windows kubectl joins them with ;.
# Unix / Git Bash (if kubectl honors colon-separated paths)
KUBECONFIG="${HOME}/.kube/config:/tmp/cks-admin.conf" kubectl config view --flatten > /tmp/kubeconfig-merged
# Windows kubectl (PowerShell)
# $env:KUBECONFIG="$env:USERPROFILE\.kube\config;$env:TEMP\cks-admin.conf"
# kubectl config view --flatten | Out-File -Encoding utf8 $env:TEMP\kubeconfig-mergedReplace ~/.kube/config with the flattened file, restore the production current-context, and delete the temp copies:
chmod 600 /tmp/kubeconfig-merged
mv /tmp/kubeconfig-merged ~/.kube/config
kubectl config use-context <prod-cluster-context>
rm -f /tmp/cks-admin.conf
kubectl config get-contexts
kubectl --context <dev-cluster-context> get nodes -o wideSwitch into the lab with kubectl config use-context <dev-cluster-context> when you want it, then back to <prod-cluster-context> when you are done. kubeadm’s default cluster and user names are kubernetes and kubernetes-admin. The renamed context is what you switch on. If you later merge a second vanilla kubeadm kubeconfig, rename those entries first so they do not collide.
9. Install Istio (ambient), optional#
After both nodes are Ready and you can reach the API from the workstation (or from the control-plane guest). Ambient is the dataplane for this lab: per-node ztunnel, not per-pod sidecars. Download a current Istio release that lists Kubernetes 1.34 as supported, then install the ambient profile.
curl -L https://istio.io/downloadIstio | sh -
cd istio-*
export PATH="$PWD/bin:$PATH"
istioctl precheck
istioctl install --set profile=ambient --skip-confirmationWait until istiod, ztunnel, and istio-cni-node are Ready in istio-system:
kubectl -n istio-system get pods
istioctl verify-installFor mesh labs, label a namespace (kubectl label namespace <ns> istio.io/dataplane-mode=ambient). You do not need that label for the control plane to be installed. Do not switch to the sidecar default profile on these two VMs. You can skip this section if you only need kubeadm and Cilium for CKS node and control-plane practice.
Destroying the VMs#
Tear Kubernetes down with kubeadm reset on each guest if you want a clean disk, then stop and Remove the two VMs in the Proxmox UI. Template 9999 and the k3s VMs stay. There is no terraform destroy for this lab.