How To Install Kubernetes on Ubuntu 26.04 LTS

Install Kubernetes on Ubuntu 26.04

Every few years, a new Ubuntu LTS release lands on a sysadmin’s desk and quietly breaks half the tutorials that used to work fine. Ubuntu 26.04 LTS is no exception. Between the updated kernel baseline, tighter default cgroup v2 enforcement, and the usual churn in package repositories, installing Kubernetes on it isn’t quite the copy-paste job it was on 20.04 or even 22.04.

Anyone who has run a cluster in anger — through a midnight traffic spike, a failed etcd write, or a kubelet that silently stopped reporting node status — knows that the install phase is where most future headaches get baked in. Skip the swap check, forget to hold your package versions, or misconfigure the container runtime’s cgroup driver, and you’ll be debugging a “node NotReady” mystery three weeks from now instead of today, when it’s cheap to fix.

This guide walks through a real, production-oriented installation of Kubernetes on Ubuntu 26.04 LTS using kubeadm, the tool most teams actually use outside of managed cloud offerings like EKS or GKE. It covers the control plane, worker node joining, container runtime setup with containerd, a CNI plugin (Calico), and the security and performance considerations that separate a “works on my laptop” cluster from one that survives real traffic.

Whether you’re standing up a homelab cluster to prep for the CKA exam, or provisioning bare-metal nodes for a company that doesn’t want to pay cloud markup on compute, the steps below are the same ones used in actual data centers. We’ll also cover k3s as a lighter alternative for edge or resource-constrained environments, because not every workload needs a full three-node control plane.

Prerequisites Before You Touch a Single Command

Kubernetes is unforgiving about environment drift. Before running any installation commands, make sure every node — control plane and workers alike — meets these baseline requirements:

  • Ubuntu 26.04 LTS installed and fully updated (sudo apt update && sudo apt upgrade -y)
  • Minimum 2 CPUs and 2GB RAM per node (4GB+ recommended for control plane nodes running anything beyond a demo)
  • Unique hostname, MAC address, and product_uuid for every node — cloned VMs are a classic source of “duplicate node” errors
  • Full network connectivity between all nodes, with no NAT blocking pod-to-pod traffic
  • Root or sudo access on every machine
  • Swap disabled (Kubernetes assumes a fixed memory model; leaving swap on causes the kubelet to refuse to start cleanly in most configurations)

A quick sanity check worth running before anything else:

hostnamectl
ip a
free -h

If two nodes report the same hostname, fix that now — hostnamectl set-hostname k8s-worker-01 — because kubeadm join failures caused by hostname collisions are annoying to diagnose after the fact.

Step 1: Disable Swap on Every Node

This trips up more first-time cluster builders than anything else in the entire process. Kubernetes’ scheduler and kubelet expect predictable memory accounting, and swap muddies that.

sudo swapoff -a
sudo sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab

The sed command comments out the swap line in /etc/fstab so it doesn’t come back after a reboot. Confirm with free -h — the swap row should read all zeros.

Step 2: Load Required Kernel Modules and Tune Sysctl

Kubernetes networking depends on the br_netfilter and overlay kernel modules to correctly handle bridged traffic between pods. Without them, you’ll get pods that can talk to the host but not to each other — a subtle failure mode that doesn’t throw obvious errors.

cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

sudo modprobe overlay
sudo modprobe br_netfilter

Next, enable IP forwarding and bridge netfilter calls at the kernel level:

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 --system

Verify the values actually applied — sysctl silently failing to persist across reboot is a real thing on minimal Ubuntu images:

sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables

Step 3: Install and Configure containerd

Docker’s built-in dockershim was removed from Kubernetes years ago, and containerd has since become the de facto container runtime for kubeadm clusters. It’s lighter, has fewer moving parts, and integrates directly with the Container Runtime Interface (CRI) that Kubernetes expects.

sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo apt install -y containerd.io

Generate the default configuration and — this is the step almost everyone forgets — switch the cgroup driver to systemd:

sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl restart containerd
sudo systemctl enable containerd

Why does the cgroup driver matter so much? Ubuntu 26.04 ships with systemd managing cgroups by default. If containerd is left on the cgroupfs driver while the kubelet expects systemd, you end up with two separate cgroup managers fighting over resource limits. It usually manifests as kubelet flapping between Ready and NotReady under load — a maddening thing to chase down if you don’t know to check this one line first.

Step 4: Install kubeadm, kubelet, and kubectl

Add the official Kubernetes package repository. Note that the old apt.kubernetes.io endpoint is deprecated — use the versioned pkgs.k8s.io repo instead, which is what current documentation and package maintainers expect.

sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.31/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.31/deb/ /' | \
  sudo tee /etc/apt/sources.list.d/kubernetes.list

sudo apt update
sudo apt install -y kubelet kubeadm kubectl

Then pin the versions so an unattended apt upgrade doesn’t yank your cluster onto an untested Kubernetes minor version mid-quarter:

sudo apt-mark hold kubelet kubeadm kubectl

That apt-mark hold line is not optional in any environment you actually care about. Kubernetes minor version upgrades are not always backward-compatible for API objects, and an automated cron-driven apt upgrade silently jumping your kubelet two minor versions ahead of your control plane is a genuinely bad way to start a Monday.

Step 5: Initialize the Control Plane

Run this only on the node you intend to be your control plane — never on workers.

sudo kubeadm init --pod-network-cidr=192.168.0.0/16

The --pod-network-cidr flag needs to match whatever CNI plugin you plan to install. Calico’s default manifest expects 192.168.0.0/16; if you’re going with Flannel instead, use 10.244.0.0/16. Getting this mismatched is one of the more common reasons pods get stuck in ContainerCreating indefinitely after a fresh init.

kubeadm init will output a kubeadm join command with a token near the end — copy that somewhere safe. It expires after 24 hours by default, though you can regenerate it later with kubeadm token create --print-join-command.

Configure kubectl access 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

Step 6: Install a CNI Plugin (Calico)

A freshly initialized control plane has no working pod networking until a CNI plugin is deployed. Without it, your nodes will sit in a NotReady state indefinitely, and that’s expected — not a bug.

kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/calico.yaml

Calico is a solid default for most production clusters because it supports NetworkPolicy enforcement out of the box, which matters the moment you need to restrict which pods can talk to which — something Flannel doesn’t do natively. Give it a minute or two, then check:

kubectl get pods -n kube-system
kubectl get nodes

The control plane node should transition to Ready once Calico’s pods report Running.

Step 7: Join Worker Nodes

Repeat Steps 1 through 4 on every worker node — swap disabled, kernel modules loaded, containerd configured, kubeadm/kubelet/kubectl installed. Then run the join command generated during kubeadm init:

sudo kubeadm join <control-plane-ip>:6443 --token <token> \
  --discovery-token-ca-cert-hash sha256:<hash>

Back on the control plane, confirm the new node registered correctly:

kubectl get nodes -o wide

Give it a moment — a node can briefly show NotReady right after joining while it pulls the CNI images.

Single-Node Clusters: Tainting for Scheduling

If this is a lab or a small single-node deployment, the control plane node won’t schedule regular workloads by default — it’s tainted to keep application pods off it. Remove that taint if you genuinely want it to run pods too:

kubectl taint nodes --all node-role.kubernetes.io/control-plane-

Do this deliberately, not by habit. In any cluster with more than one node, keeping the control plane untainted and workload-free is the correct default — mixing control plane and application traffic on the same node is how a runaway pod ends up starving etcd of CPU cycles during an incident.

An Alternative Path: k3s for Lightweight Deployments

Full kubeadm clusters aren’t always the right tool. For edge devices, CI runners, IoT gateways, or a quick dev cluster on a single VPS, k3s — Rancher’s certified lightweight Kubernetes distribution — is genuinely a better fit. It ships as a single binary under 100MB and bundles its own lightweight datastore, ingress controller, and load balancer.

curl -sfL https://get.k3s.io | sh -
sudo systemctl status k3s

Configure kubectl access:

mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $USER:$USER ~/.kube/config
kubectl get nodes

Adding additional nodes is a one-liner using the token from the server:

sudo cat /var/lib/rancher/k3s/server/node-token

Run this on each new agent node, substituting your server’s IP and the token retrieved above:

curl -sfL https://get.k3s.io | K3S_URL=https://SERVER_IP:6443 K3S_TOKEN=NODE_TOKEN sh -

For production API workloads that need the full upstream Kubernetes feature set — admission controllers, custom schedulers, advanced RBAC — kubeadm is still the more defensible choice. For anything resource-constrained or where operational simplicity outweighs feature completeness, k3s wins.

Security Hardening You Shouldn’t Skip

A default kubeadm cluster is functional but nowhere near production-hardened. A few things worth doing immediately after the cluster comes up:

  • Enable RBAC and audit logging from day one. Retrofitting RBAC onto a cluster where every service account has cluster-admin by habit is painful and risky.
  • Never expose the API server (port 6443) directly to the public internet. Put it behind a VPN, bastion host, or private subnet, and restrict access with firewall rules (ufw allow from <trusted-subnet> to any port 6443).
  • Apply NetworkPolicies to restrict pod-to-pod traffic by namespace. Calico makes this straightforward — a default-deny policy per namespace, with explicit allow rules, closes off a huge amount of lateral-movement risk if a single pod gets compromised.
  • Keep the OS patched independently of Kubernetes. unattended-upgrades for security patches is fine on Ubuntu 26.04, but exclude the held Kubernetes packages so it doesn’t clash with the apt-mark hold you set earlier.
  • Rotate certificates. kubeadm-managed clusters use certificates with a one-year default expiry; kubeadm certs check-expiration should be part of your regular maintenance routine, not something you discover has lapsed during an outage.

Performance Tuning for Real Workloads

Once the cluster is running, the tuning work that actually matters happens under load, not at install time.

CPU and memory: Set resource requests and limits on every deployment. Without them, the scheduler has no basis for bin-packing decisions, and a single noisy pod can starve its neighbors on the same node during a traffic spike. Requests should reflect steady-state usage; limits should account for legitimate bursts, not be set arbitrarily high “to be safe.”

Disk I/O: etcd is extremely latency-sensitive to disk writes. On bare metal or VMs, put etcd’s data directory on NVMe or fast SSD storage, not spinning disks or network-attached storage with variable latency. A slow disk under etcd shows up as API server timeouts that look, at first glance, like a networking problem.

Network: Calico’s default IP-in-IP encapsulation adds a small amount of overhead. On a flat network where all nodes are on the same L2 segment, switching to BGP mode (no encapsulation) reduces latency measurably — worth doing once the cluster is stable and you’re chasing the last bit of network performance.

Kubelet eviction thresholds: Default kubelet eviction settings are conservative. On memory-constrained nodes, tune --eviction-hard thresholds in the kubelet config so pods get evicted gracefully before the node’s OOM killer starts making arbitrary decisions about which process dies.

Troubleshooting Common Installation Errors

“kubelet isn’t running or healthy” during kubeadm init:
Almost always a swap or cgroup driver mismatch. Run sudo journalctl -xeu kubelet and check for cgroup driver errors specifically. Confirm containerd’s SystemdCgroup setting matches what kubeadm expects.

Nodes stuck in NotReady after joining:
Usually means the CNI plugin isn’t installed yet, or its pods haven’t finished pulling images. Check with kubectl get pods -n kube-system -o wide and look for ImagePullBackOff — often a DNS resolution issue on the node preventing it from reaching the image registry.

“connection refused” on kubeadm join:
Check that port 6443 is reachable from the worker to the control plane node, and that ufw (if enabled) isn’t silently dropping the traffic. sudo ufw status verbose on the control plane will tell you quickly.

Pods can’t resolve DNS:
Check CoreDNS pods are running (kubectl get pods -n kube-system -l k8s-app=kube-dns). If they’re crash-looping, it’s frequently a resource limit that’s too tight for CoreDNS under load, or a /etc/resolv.conf misconfiguration on the host that’s being inherited unexpectedly.

Token expired for kubeadm join:
Generate a fresh one rather than troubleshooting the old one:

kubeadm token create --print-join-command
r00t is a Linux Systems Administrator and open-source advocate with over ten years of hands-on experience in server infrastructure, system hardening, and performance tuning. Having worked across distributions such as Debian, Arch, RHEL, and Ubuntu, he brings real-world depth to every article published on this blog. r00t writes to bridge the gap between complex sysadmin concepts and practical, everyday application — whether you are configuring your first server or optimizing a production environment. Based in New York, US, he is a firm believer that knowledge, like open-source software, is best when shared freely.

Related Posts