kubeadm is the official tool for bootstrapping a Kubernetes cluster that follows upstream best practices. Unlike managed services (EKS, AKS, GKE) it shows you every component, which is exactly what you need to understand Kubernetes and to pass the CKA exam. This tutorial builds a cluster with one control plane and two workers on Ubuntu 24.04, using containerd as the runtime, the current pkgs.k8s.io package repositories, Kubernetes 1.33 and Calico as the pod network. Older guides that use apt-key, apt.kubernetes.io or Docker as runtime no longer work; everything below has been checked against the 2026 tooling.
Prerequisites: three Ubuntu 24.04 machines (VMs or cloud instances) with at least 2 vCPU and 2 GB RAM each (4 GB recommended for the control plane), unique hostnames, static IPs on the same network, sudo access, and open ports: 6443, 2379–2380, 10250, 10257, 10259 on the control plane; 10250 and 30000–32767 on workers; plus TCP 179 and IP-in-IP (protocol 4) for Calico on all nodes.
Lab layout
| Role | Hostname | IP (example) |
|---|---|---|
| Control plane | cp1 | 192.168.56.10 |
| Worker | w1 | 192.168.56.11 |
| Worker | w2 | 192.168.56.12 |
The pod network CIDR will be 192.168.0.0/16 (Calico’s default). If your node network already uses 192.168.x.x, pick 10.244.0.0/16 instead and adjust both kubeadm init and the Calico configuration.
Step 1 – Prepare all three nodes
Run this section on every node. Kubernetes requires swap to be off, and the kernel must forward IPv4 traffic and let iptables see bridged traffic.
# Hostnames (adapt per node)
sudo hostnamectl set-hostname cp1
echo "192.168.56.10 cp1
192.168.56.11 w1
192.168.56.12 w2" | sudo tee -a /etc/hosts
# Disable swap now and at boot
sudo swapoff -a
sudo sed -i '/\sswap\s/s/^/#/' /etc/fstab
# Kernel modules and sysctl
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay && sudo modprobe br_netfilter
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 --systemStep 2 – Install containerd
Kubernetes talks to the runtime through CRI; containerd is the standard choice. The one setting that trips everyone is the cgroup driver: kubelet defaults to systemd, so containerd must too.
sudo apt-get update
sudo apt-get install -y containerd
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml > /dev/null
# Use the systemd cgroup driver and the current pause image
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo sed -i 's#sandbox_image = .*#sandbox_image = "registry.k8s.io/pause:3.10"#' /etc/containerd/config.toml
sudo systemctl restart containerd
sudo systemctl enable containerd
systemctl status containerd --no-pager | head -3Step 3 – Install kubeadm, kubelet and kubectl
Packages come from the community-owned pkgs.k8s.io repositories, one repository per minor version. The signing key is stored with signed-by; apt-key is gone.
K8S_MINOR=v1.33
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:/${K8S_MINOR}/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:/${K8S_MINOR}/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 # upgrades are done deliberately with kubeadm
sudo systemctl enable --now kubelet
kubeadm version -o shortTo install a specific patch, list versions with apt-cache madison kubeadm and pin, for example, kubelet=1.33.4-1.1 kubeadm=1.33.4-1.1 kubectl=1.33.4-1.1. Kubelet will crash-loop until kubeadm init or join gives it a configuration; that is expected.
Step 4 – Initialise the control plane (cp1 only)
sudo kubeadm init \
--pod-network-cidr=192.168.0.0/16 \
--apiserver-advertise-address=192.168.56.10 \
--control-plane-endpoint=cp1 \
--kubernetes-version=stable-1.33--control-plane-endpoint is optional today but lets you add more control-plane nodes later without re-initialising. After a minute the command prints a success message with a kubeadm join line: copy it. Then configure kubectl for your user:
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 nodes
# NAME STATUS ROLES AGE VERSION
# cp1 NotReady control-plane 60s v1.33.4The node is NotReady because no pod network exists yet; CoreDNS pods stay Pending for the same reason.
Step 5 – Install Calico
Calico is installed through its operator: first the operator and CRDs, then a Installation resource describing the network. Check the Calico quickstart for the latest version number and replace it below.
CALICO_VERSION=v3.30.2
kubectl create -f "https://raw.githubusercontent.com/projectcalico/calico/${CALICO_VERSION}/manifests/tigera-operator.yaml"
# Custom resources: only edit cidr if you changed --pod-network-cidr
curl -fsSLO "https://raw.githubusercontent.com/projectcalico/calico/${CALICO_VERSION}/manifests/custom-resources.yaml"
grep cidr custom-resources.yaml # should show 192.168.0.0/16
kubectl create -f custom-resources.yaml
# Wait until every Calico component is Available
watch kubectl get tigerastatus# Excerpt of custom-resources.yaml – the part you may need to change
apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
name: default
spec:
calicoNetwork:
ipPools:
- name: default-ipv4-ippool
blockSize: 26
cidr: 192.168.0.0/16
encapsulation: VXLANCrossSubnet
natOutgoing: Enabled
nodeSelector: all()Once tigerastatus shows apiserver, calico and ippools as Available, the control plane turns Ready and CoreDNS starts running.
Step 6 – Join the workers (w1 and w2)
# Paste the join command printed by kubeadm init, e.g.:
sudo kubeadm join cp1:6443 --token abcdef.0123456789abcdef \
--discovery-token-ca-cert-hash sha256:<HASH_FROM_INIT_OUTPUT>
# Lost the command? Generate a fresh one on cp1 (tokens expire after 24 h):
kubeadm token create --print-join-commandStep 7 – Verify the cluster
kubectl get nodes -o wide
# NAME STATUS ROLES AGE VERSION INTERNAL-IP OS-IMAGE CONTAINER-RUNTIME
# cp1 Ready control-plane 12m v1.33.4 192.168.56.10 Ubuntu 24.04.2 LTS containerd://1.7.27
# w1 Ready <none> 3m v1.33.4 192.168.56.11 ...
# w2 Ready <none> 2m v1.33.4 192.168.56.12 ...
kubectl get pods -A # all Running
kubectl label node w1 w2 node-role.kubernetes.io/worker= # cosmetic role label
# Smoke test: deploy nginx, expose it, curl it from a node
kubectl create deployment web --image=nginx:1.27 --replicas=2
kubectl expose deployment web --port=80 --type=NodePort
kubectl get svc web # note the 3xxxx NodePort
curl -s http://192.168.56.11:<NODEPORT> | grep -o '<title>.*</title>'
# Pod-to-pod DNS across nodes
kubectl run -it --rm dns-test --image=busybox:1.36 --restart=Never -- nslookup web.default.svc.cluster.localOptional: run workloads on the control plane
Single-node labs need to remove the control-plane taint so ordinary pods can schedule there:
kubectl taint nodes --all node-role.kubernetes.io/control-plane-Troubleshooting
- kubeadm init fails on preflight “swap is enabled” – re-run
swapoff -a; on cloud images also maskswap.img.swap. - Pods stuck in ContainerCreating / “cgroup driver mismatch” –
SystemdCgroup = truewas not applied; check/etc/containerd/config.tomland restart containerd, thenkubeadm reset -fand start again. - Node NotReady forever – Calico not running:
kubectl get pods -n calico-system,kubectl describe tigerastatus calico. Common cause: firewall blocking BGP (179) or VXLAN (UDP 4789). - Join fails with certificate hash mismatch – token expired or copied badly; generate a new join command.
- Everything broken, start over – on the node:
sudo kubeadm reset -f && sudo rm -rf /etc/cni/net.d $HOME/.kube.
Upgrading later
Because the packages are on hold, upgrades are explicit: change K8S_MINOR in the repository file, apt-get update, unhold and install the new kubeadm, run kubeadm upgrade plan then kubeadm upgrade apply v1.34.x on the control plane, and finally upgrade kubelet/kubectl node by node after draining each one. The full procedure is in Upgrading Kubernetes.
Key takeaways
- 2026 stack: Ubuntu 24.04, containerd with the systemd cgroup driver,
pkgs.k8s.iorepos, Kubernetes 1.33, Calico via operator. kubeadm initon the control plane,kubeadm joinon workers; hold the packages and upgrade deliberately.- Nodes are NotReady until a CNI plugin is installed; that is by design.
- Always verify with a real deployment, a NodePort and a DNS lookup across nodes.
Next tutorial
Next: etcd, the cluster datastore and CNI plugins compared. Official docs: Creating a cluster with kubeadm, Container runtimes.