
Anyone who has built a Kubernetes cluster by hand knows the feeling. The tutorial said ten minutes. Two hours later the node is stuck in NotReady, the kubelet is crash-looping, and the logs blame something unrelated. Most of those failures trace back to a handful of small details: swap, cgroup drivers, kernel modules, a stale repository key. They are rarely Kubernetes bugs.
Ubuntu 26.04 LTS adds a few wrinkles of its own. It ships Linux kernel 7.0 and is supported until April 2031, which makes it a sensible base for a cluster you plan to keep for years. It also changes some defaults that older guides never mention. The release dropped cgroup v1 support entirely, so container workloads will not run on a host booted with it. Both sudo and coreutils now default to Rust implementations. None of this breaks a Kubernetes install, but a guide written for 22.04 or 24.04 can mislead you in small ways.
This guide builds a real, working multi-node cluster using kubeadm, containerd and a CNI plugin. It uses the current Kubernetes v1.37 package line, which is what the official kubeadm documentation covers right now. The commands work as written on Ubuntu 26.04. Where Debian or AlmaLinux differ, the guide says so. Along the way it explains why each step exists, because a cluster you understand is a cluster you can fix at 3 a.m.
What You Are Building
The target is a small, honest cluster: one control plane node and two workers. That is the smallest layout that behaves like production. A single-node cluster hides networking problems that appear as soon as pods land on different machines.
| Role | Hostname | Suggested specs (lab) | Suggested specs (production) |
|---|---|---|---|
| Control plane | k8s-cp01 | 2 vCPU, 4 GB RAM, 40 GB SSD | 4 vCPU, 8 GB RAM, NVMe, x3 for HA |
| Worker 1 | k8s-w01 | 2 vCPU, 4 GB RAM, 40 GB SSD | Sized to workload |
| Worker 2 | k8s-w02 | 2 vCPU, 4 GB RAM, 40 GB SSD | Sized to workload |
The hard minimum for kubeadm is 2 CPUs and 2 GB of RAM per machine. Below that, preflight checks fail. In practice, 4 GB on the control plane is more comfortable, because etcd and the API server both like headroom.
The stack looks like this:
- Container runtime: containerd, installed from the Ubuntu archive.
- Bootstrap tool: kubeadm, with kubelet and kubectl from the official
pkgs.k8s.iorepository. - Pod network: Flannel, chosen for simplicity. Calico or Cilium are better choices if you need network policies.
Why kubeadm and not k3s, MicroK8s or a managed service? Because kubeadm produces a vanilla upstream cluster. It is the same layout you will meet in certification exams and in plenty of on-prem production environments. It also teaches you where everything lives.
Prerequisites and Planning
Before touching a keyboard, settle a few things. Skipping this section is how people end up rebuilding clusters.
Network and addressing. Give every node a static IP or a DHCP reservation. A control plane whose address changes is a broken control plane, because the API server certificate embeds that address. Make sure the nodes can reach each other on all the ports listed later in this guide.
Unique identity. Each node needs a unique hostname, MAC address and product_uuid. Cloned VMs are the classic trap. If you cloned a template, check this:
hostnamectl
cat /sys/class/dmi/id/product_uuid
ip link show
Duplicate UUIDs or machine IDs lead to nodes that register, then silently overwrite each other. If machine IDs match on cloned VMs, regenerate them:
sudo rm -f /etc/machine-id /var/lib/dbus/machine-id
sudo systemd-machine-id-setup
Access. Log in with a normal user that has sudo rights. Ubuntu 26.04 uses sudo-rs by default, and ordinary commands such as sudo apt update behave the same. A few exotic sudo flags may differ. If an old automation script chokes on one, the original sudo remains installable as an alternative provider.
Confirm cgroup v2. This is effectively guaranteed on 26.04, but checking costs one second:
stat -fc %T /sys/fs/cgroup/
The output should be cgroup2fs. Ubuntu 26.04’s systemd no longer supports cgroup v1 or hybrid hierarchies, so a different result would point to something badly wrong with the host.
Step 1: Prepare Every Node
Run everything in this section on all three machines. Using tmux with synchronized panes, or a quick Ansible run, saves a lot of repetition.
Update the system and set hostnames
sudo apt update && sudo apt full-upgrade -y
sudo hostnamectl set-hostname k8s-cp01 # change per node
Reboot if a new kernel was installed. Mismatched running and installed kernels cause confusing module errors later, particularly with overlay and br_netfilter.
Add name resolution so nodes can find each other without relying on DNS:
sudo tee -a /etc/hosts <<'EOF'
192.168.10.10 k8s-cp01
192.168.10.11 k8s-w01
192.168.10.12 k8s-w02
EOF
Adjust the addresses to match your network. In production, real DNS records are better, but /etc/hosts is a useful safety net when DNS itself runs inside the cluster.
Disable swap
The kubelet refuses to start with swap enabled by default. The scheduler assumes that the memory it hands out is real memory. Swap muddies that, and a node that starts swapping heavily becomes slow in ways that are hard to attribute.
sudo swapoff -a
sudo sed -i '/\sswap\s/ s/^/#/' /etc/fstab
Then check for swap that fstab does not list. Ubuntu cloud images sometimes use a swapfile or a systemd swap unit:
swapon --show
systemctl list-units --type=swap
If anything shows up, mask the unit or remove the file. Kubernetes does have a NodeSwap feature for specific workloads, but a first install should leave swap off.
Load kernel modules
Two modules matter. overlay supports the container filesystem layers. br_netfilter lets iptables and nftables rules see bridged traffic, which is how pod-to-service traffic gets processed.
sudo tee /etc/modules-load.d/k8s.conf <<'EOF'
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter
lsmod | grep -E 'overlay|br_netfilter'
Apply sysctl settings
sudo tee /etc/sysctl.d/99-kubernetes.conf <<'EOF'
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sudo sysctl --system
IP forwarding is the one that matters most. Without it, a node will not route packets between pods and the outside world, and pods on different nodes will fail to reach each other. A pod that cannot reach another node’s pod, while everything looks healthy, is very often this setting.
Step 2: Install and Configure containerd
Kubernetes talks to the runtime through the Container Runtime Interface, and containerd is the most widely deployed choice. Docker Engine is not required, and mixing it in only adds a layer.
sudo apt install -y containerd
Ubuntu 26.04 packages containerd and runc in its archive. For most clusters the archive package is fine, and it gets security updates through the normal channel. If you need a specific upstream containerd release, Docker’s apt repository is the usual alternative.
Generate a full default config and edit it:
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml > /dev/null
Now the critical edit. The kubelet and containerd must agree on who manages cgroups. On any systemd-based distribution, the answer is systemd:
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
grep -n SystemdCgroup /etc/containerd/config.toml
Why does this matter so much? If the runtime uses cgroupfs while the kubelet uses systemd, two separate managers fight over the same cgroup tree. The result is pods that restart unpredictably under memory pressure, and nodes that become unstable days after installation, when everything looked fine on day one. Recent kubeadm versions default the kubelet to the systemd driver, so containerd is the side you need to fix.
Restart and enable the service:
sudo systemctl restart containerd
sudo systemctl enable containerd
systemctl status containerd --no-pager
Install the CNI-independent debugging tool crictl later in this guide. It is how you inspect containers without involving Kubernetes at all.
Step 3: Install kubeadm, kubelet and kubectl
Kubernetes packages live in the community-owned repository at pkgs.k8s.io. The old Google-hosted repositories were deprecated and frozen in September 2023, and the project says the new ones are required for anything released afterwards. If an old blog post tells you to add apt.kubernetes.io, close the tab.
The repository is split by minor version. Pick the one you want in the URL. This guide uses v1.37, matching the current kubeadm installation guide.
sudo apt 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.37/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.37/deb/ /' | \
sudo tee /etc/apt/sources.list.d/kubernetes.list
Then install and pin the packages:
sudo apt update
sudo apt install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl
The hold is not optional in a serious environment. An unattended apt upgrade that bumps kubelet by a minor version, on one node, at the wrong moment, is how clusters end up with version skew and a very bad morning. Upgrades should be deliberate, one node at a time. The kubeadm upgrade documentation walks through exactly that sequence, and it uses the same unhold, install, hold pattern.
Enable the kubelet. It will restart every few seconds until kubeadm init or join gives it a config. That crash loop is normal at this stage and not a sign of failure:
sudo systemctl enable --now kubelet
Optionally install crictl for runtime debugging, and point it at containerd:
sudo tee /etc/crictl.yaml <<'EOF'
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
EOF
Alma, Rocky and CentOS Stream notes
On RHEL-family systems the steps are the same in spirit but the tooling differs. Use dnf, set SELinux to permissive as the upstream docs describe (or write proper policy), and use the matching rpm repository from pkgs.k8s.io. Firewalld replaces UFW. The sysctl and module steps are identical.
Step 4: Open the Firewall Properly
Plenty of guides say “disable the firewall.” Do not. A cluster node with no firewall is a gift. Open only what is needed.
Control plane nodes:
| Port | Protocol | Purpose |
|---|---|---|
| 6443 | TCP | Kubernetes API server |
| 2379-2380 | TCP | etcd client and peer |
| 10250 | TCP | Kubelet API |
| 10257 | TCP | Controller manager |
| 10259 | TCP | Scheduler |
Worker nodes:
| Port | Protocol | Purpose |
|---|---|---|
| 10250 | TCP | Kubelet API |
| 10256 | TCP | kube-proxy |
| 30000-32767 | TCP/UDP | NodePort services |
With UFW on the control plane:
sudo ufw allow 22/tcp
sudo ufw allow from 192.168.10.0/24 to any port 6443 proto tcp
sudo ufw allow from 192.168.10.0/24 to any port 2379:2380 proto tcp
sudo ufw allow from 192.168.10.0/24 to any port 10250 proto tcp
sudo ufw allow from 192.168.10.0/24 to any port 10257 proto tcp
sudo ufw allow from 192.168.10.0/24 to any port 10259 proto tcp
sudo ufw enable
Flannel’s VXLAN backend also needs UDP 8472 between all nodes:
sudo ufw allow from 192.168.10.0/24 to any port 8472 proto udp
One honest caveat: host firewalls and CNI plugins sometimes interact badly, since both manipulate packet filtering rules. If pod traffic breaks right after enabling UFW, set DEFAULT_FORWARD_POLICY in /etc/default/ufw to ACCEPT and compare. Test connectivity before and after, so you know which change caused what.
Step 5: Initialize the Control Plane
On k8s-cp01 only. First, a dry run, which catches most preflight issues without changing anything:
sudo kubeadm init --dry-run \
--pod-network-cidr=10.244.0.0/16 \
--apiserver-advertise-address=192.168.10.10
Pre-pulling images is also worth doing. It splits the slow network step from the initialization:
sudo kubeadm config images pull
For anything beyond a throwaway lab, use a config file instead of flags. It is reviewable, version-controllable and repeatable:
cat <<'EOF' | sudo tee kubeadm-config.yaml
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 192.168.10.10
bindPort: 6443
nodeRegistration:
criSocket: unix:///run/containerd/containerd.sock
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
networking:
podSubnet: 10.244.0.0/16
serviceSubnet: 10.96.0.0/12
controlPlaneEndpoint: "k8s-cp01:6443"
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
EOF
sudo kubeadm init --config kubeadm-config.yaml --upload-certs
If the v1beta4 API version is rejected on your build, run kubeadm config print init-defaults to see which version your installed kubeadm expects, and adapt. Config API versions do get retired over time, which is another reason to read the output of that command rather than trusting copy-pasted YAML.
About controlPlaneEndpoint: set it now, even for a single control plane. Later, when you want high availability, it can point at a load balancer or a virtual IP. Adding it afterwards means regenerating certificates, which is far more annoying than choosing a stable name on day one.
A successful run ends with a kubeadm join command. Copy it somewhere safe. Then configure kubectl for your regular 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
The control plane will report NotReady. That is expected. No pod network exists yet, so the kubelet has no CNI configuration and holds the node in that state.
Step 6: Install a Pod Network
Kubernetes ships no networking implementation of its own. It defines the requirements and leaves the job to a CNI plugin. Pick one before you add workers.
Flannel is the lightest option. It gives you a flat overlay and nothing else:
kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml
kubectl get pods -n kube-flannel -w
Its default network is 10.244.0.0/16, which is why the init config used that pod subnet. If you chose a different CIDR, you must edit Flannel’s ConfigMap to match. A mismatch here is one of the most common causes of pods stuck in ContainerCreating.
Which CNI should you pick for production? A short comparison helps.
| CNI | Strength | Trade-off |
|---|---|---|
| Flannel | Simple, minimal overhead | No network policies |
| Calico | Network policies, BGP routing, mature | More moving parts |
| Cilium | eBPF, observability, strong policy engine | Needs a recent kernel and more learning |
Ubuntu 26.04’s kernel 7.0 is more than recent enough for Cilium’s requirements. If you expect to enforce policies between namespaces, which most multi-team clusters eventually do, start with Calico or Cilium. Migrating CNIs on a live cluster is painful.
Wait until the node flips to Ready:
kubectl get nodes
kubectl get pods -A
Step 7: Join the Worker Nodes
On each worker, run the join command that kubeadm init printed:
sudo kubeadm join k8s-cp01:6443 --token <token> \
--discovery-token-ca-cert-hash sha256:<hash>
Tokens expire after 24 hours. If yours has lapsed, generate a fresh command on the control plane:
kubeadm token create --print-join-command
Verify from the control plane:
kubectl get nodes -o wide
Both workers should appear and move to Ready within a minute or two, once the Flannel pod starts on each. Label them so workload placement stays readable:
kubectl label node k8s-w01 node-role.kubernetes.io/worker=
kubectl label node k8s-w02 node-role.kubernetes.io/worker=
Then run a smoke test. Do not declare victory because nodes say Ready. Prove that scheduling, networking and service routing work:
kubectl create deployment web --image=nginx:stable --replicas=3
kubectl expose deployment web --port=80 --type=NodePort
kubectl get pods -o wide
kubectl get svc web
Pods should be spread across both workers. Hit the NodePort on each node’s IP with curl. Then test DNS from inside the cluster:
kubectl run tmp --rm -it --image=busybox:stable --restart=Never -- nslookup web
If DNS resolves and curl works from every node, the cluster is functional.
Troubleshooting Common Errors
Real installs fail in predictable ways. Here are the ones that show up most.
Node stuck in NotReady
Start with the describe output, not guesses:
kubectl describe node k8s-w01
journalctl -u kubelet -n 100 --no-pager
“Network plugin returns error: cni plugin not initialized” means the CNI pod is not running on that node. Check kubectl get pods -n kube-flannel -o wide. Often the cause is a blocked UDP 8472, or a failed image pull.
kubelet crash loop after kubeadm init
Look at the actual error:
sudo journalctl -xeu kubelet
The usual suspects are swap still enabled (running with swap on is not supported), a cgroup driver mismatch, or a missing containerd socket. Verify with sudo systemctl status containerd and ls /run/containerd/containerd.sock.
Preflight error: bridge-nf-call-iptables does not exist
The br_netfilter module is not loaded. Reload it and recheck the sysctl values:
sudo modprobe br_netfilter
sysctl net.bridge.bridge-nf-call-iptables
Control plane pods restarting repeatedly
Classic symptom of the cgroup driver problem. Confirm SystemdCgroup = true in the containerd config, restart containerd, then restart the kubelet. If the cluster was already initialized with the wrong driver, fix both sides, then watch crictl ps -a to see whether containers still churn.
The connection to the server localhost:8080 was refused
Your kubeconfig is missing. Redo the ~/.kube/config copy step. If you are running as root, export KUBECONFIG=/etc/kubernetes/admin.conf works too.
Pods stuck in ContainerCreating or ImagePullBackOff
Describe the pod and read the events at the bottom:
kubectl describe pod <name>
sudo crictl images
Common causes: wrong CNI CIDR, no outbound internet or DNS from the node, a registry rate limit, or a proxy setting that containerd does not inherit. Containerd needs proxy variables set through a systemd drop-in, since shell exports do not reach it.
Certificate or token errors on join
“Couldn’t validate the identity of the API server” typically means an expired token or a wrong CA hash. Create a new token. Also check for clock drift. Certificates are time-sensitive, and a worker whose clock is ten minutes off will reject perfectly valid ones. Make sure chrony is running on every node:
timedatectl
chronyc tracking
Reset and retry cleanly
When a node is beyond saving, wipe it and start fresh:
sudo kubeadm reset -f
sudo rm -rf /etc/cni/net.d $HOME/.kube
sudo systemctl restart containerd
kubeadm reset does not clean up CNI configs or iptables rules, which is why leftover state sometimes poisons a second attempt. Reboot after resetting if networking acts strangely.