Scope. This article builds a Kubernetes 1.36 cluster from scratch on Ubuntu 24.04 LTS using kubeadm. One control-plane node, one worker node, containerd as the runtime, and Cilium as the CNI. It does not cover high availability, persistent storage, or production hardening.
What This Procedure Produces
At the end of this guide, you will have a two-node Kubernetes cluster: one control plane running the API server, etcd, the scheduler, and the controller manager, and one worker node running the kubelet and ready to schedule pods. Cilium handles pod networking and provides network policy enforcement via eBPF.
The procedure takes approximately thirty minutes per node. Most of the time is spent installing packages and waiting for container images to pull.
Prerequisites
Both nodes need Ubuntu 24.04 LTS with at least 2 CPUs, 2 GB of RAM, and network connectivity between them. The control-plane node needs an additional 2 GB for etcd. Both nodes must be able to reach the Kubernetes and Docker package repositories over HTTPS. Each node also needs a unique hostname, a unique MAC address, and a unique product UUID — kubeadm checks all three during preflight and rejects duplicates.
The steps in sections 3 through 6 must be executed on both nodes unless stated otherwise. The kubeadm init command runs only on the control plane. The kubeadm join command runs only on the worker.
Disabling Swap and Loading Kernel Modules
The kubelet refuses to start if swap is active. This is not a recommendation — it is a hard requirement. The kubelet checks for swap at startup and exits with an error if it finds any.
swapoff -a
To make this persistent across reboots, comment out or remove any swap entry in /etc/fstab.
Kubernetes networking requires two kernel modules: overlay for container filesystem layers and br_netfilter for bridge network filtering. Load them and make them persistent:
modprobe overlay modprobe br_netfilter
Then configure the kernel parameters that allow traffic forwarding between containers. Create the file /etc/sysctl.d/kubernetes.conf with the following content:
net.bridge.bridge-nf-call-ip6tables = 1 net.bridge.bridge-nf-call-iptables = 1 net.ipv4.ip_forward = 1
Apply the configuration:
sysctl --system
Without ip_forward = 1, packets between pods on different nodes are silently dropped. The cluster appears to work — pods start, services get IPs — but cross-node communication fails.
Installing containerd
Kubernetes needs a container runtime that implements the Container Runtime Interface (CRI). Containerd is the standard choice. Install it from the Docker repository:
apt install -y ca-certificates curl gnupg mkdir -p /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
Add the repository and install containerd:
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \ | tee /etc/apt/sources.list.d/docker.list apt update apt install -y containerd.io
Generate the default configuration and enable the SystemdCgroup driver:
containerd config default | tee /etc/containerd/config.toml sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml systemctl restart containerd
The SystemdCgroup = true line is critical. Without it, containerd uses the cgroupfs driver while the kubelet uses systemd. The two drivers manage cgroups independently, and containers end up with inconsistent resource accounting. The symptom is pods restarting in loops with no clear error in the pod logs — the issue is in the kubelet’s journal.
Installing kubeadm, kubelet, and kubectl
Add the Kubernetes package repository for the 1.36 branch:
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.36/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.36/deb/ /" \ | tee /etc/apt/sources.list.d/kubernetes.list apt update
Install and hold the packages to prevent automatic upgrades:
apt install -y kubeadm kubelet kubectl apt-mark hold kubeadm kubelet kubectl
The apt-mark hold is important. A routine apt upgrade that bumps kubelet to a newer minor version than the API server creates a version skew that can break the cluster. Kubernetes supports a kubelet one minor version behind the API server, but not ahead of it.
Initializing the Control Plane
This section runs only on the control-plane node.
Create a kubeadm configuration file at /root/kubeadm-config.yaml with the following content:
apiVersion: kubeadm.k8s.io/v1beta4 kind: ClusterConfiguration kubernetesVersion: v1.36.4 controlPlaneEndpoint: "CONTROL_PLANE_IP:6443" networking: podSubnet: 10.244.0.0/16 --- apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration cgroupDriver: systemd
Replace CONTROL_PLANE_IP with the actual IP address or resolvable hostname of the control-plane node. The podSubnet defines the CIDR range for pod IPs — Cilium will use this range.
Initialize the cluster:
kubeadm init --config=/root/kubeadm-config.yaml --upload-certs \ | tee /root/kubeadm-init.out
This command takes two to five minutes. It pulls the control-plane container images, generates the certificates, starts etcd, and initializes the API server. Save the output. The last lines contain the kubeadm join command that the worker node needs.
Configure kubectl for a non-root user:
mkdir -p $HOME/.kube sudo cp /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config
Check the node status:
kubectl get nodes
The node will show NotReady. This is expected. The kubelet reports NotReady because no CNI plugin is installed yet. Without a CNI, the kubelet cannot configure the pod network interface, and it refuses to mark the node as Ready. This changes in the next step.
Installing Cilium
This section runs only on the control-plane node.
Cilium is deployed via Helm. Install Helm if it is not already present:
sudo apt install -y helm
Add the Cilium Helm repository and install Cilium:
helm repo add cilium https://helm.cilium.io/ helm repo update helm install cilium cilium/cilium \ --namespace kube-system \ --set ipam.mode=cluster-pool \ --set ipam.operator.clusterPoolIPv4PodCIDRList=10.244.0.0/16
The clusterPoolIPv4PodCIDRList must match the podSubnet from the kubeadm configuration. A mismatch results in pods receiving IPs outside the expected range, and kube-proxy rules fail to route traffic to them.
Wait for the Cilium pods to be ready:
kubectl wait --for=condition=ready pod -l k8s-app=cilium \ -n kube-system --timeout=300s
Once Cilium is running, check the node status again:
kubectl get nodes
The control-plane node should now show Ready.
Joining the Worker Node
This section runs only on the worker node. The worker must have completed sections 3 through 6 (swap, kernel modules, containerd, kubeadm packages).
Run the kubeadm join command from the init output. It looks like this:
sudo kubeadm join CONTROL_PLANE_IP:6443 --token TOKEN \ --discovery-token-ca-cert-hash sha256:HASH
The token expires after 24 hours. If you prepared the control plane one day and the worker the next, the join will fail with a token error. Generate a new token from the control plane:
kubeadm token create --print-join-command
This prints a complete, ready-to-paste join command with a fresh token.
Validating the Cluster
From the control-plane node, verify that both nodes are Ready:
kubectl get nodes
Both nodes must show Ready. If the worker shows NotReady, wait one to two minutes for the Cilium agent to deploy on it.
Verify that all system pods are running:
kubectl get pods -A
Every pod must be in Running state. The Cilium pods must be present on both nodes. The CoreDNS pods must be Running — they depend on the CNI and will stay Pending until Cilium is operational.
Deploy a test workload to confirm end-to-end functionality:
kubectl create deployment nginx --image=nginx:latest kubectl expose deployment nginx --type=NodePort --port=80 kubectl get pods -o wide
The nginx pod should be scheduled on the worker node. Verify that you can reach it by curling the NodePort from either node. Then clean up:
kubectl delete deployment nginx kubectl delete service nginx
A working kubeadm cluster does not enforce Pod Security Standards by default — it only warns. The article on what your kubeadm cluster does not enforce covers this gap and how to close it.
For Kubernetes deployments that go beyond the lab — multi-cluster management, OpenShift integration, or production-grade orchestration — our cloud-native orchestration practice covers the full stack.
What This Article Does Not Cover
This article builds a minimal two-node cluster. It does not cover high availability (multiple control planes behind a load balancer), which requires at least three control-plane nodes and is covered by the HA cluster exercise in our training material. It does not cover persistent storage (CSI drivers, PersistentVolumes, StorageClasses), ingress controllers, or TLS certificate management. It does not cover Cilium’s advanced features (network policies, Hubble observability, service mesh). And it does not cover alternative distributions such as OpenShift, RKE2, or k3s.
The Kubernetes version used in this article is 1.36.4, released on 20 August 2026. Kubernetes 1.37 is expected on 26 August 2026.
References
Kubernetes Project. kubeadm Reference Documentation — Creating a Cluster with kubeadm. kubernetes.io, 2026.
Cilium Project. Installation Using Helm. docs.cilium.io, 2026.
Kubernetes Project. Container Runtimes — containerd Configuration. kubernetes.io, 2026.
EC INTELLIGENCE. Formation Administration Kubernetes — Exercice d’Installation d’un Cluster avec kubeadm. 2026.