Building a Kubernetes Cluster with kubeadm: One Control Plane, One Worker

admineci

admineci

Author

1395 words

A complete kubeadm walkthrough on Ubuntu 24.04 with Kubernetes 1.36, containerd, and Cilium. From kernel modules to a validated two-node cluster with a working CNI.

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.

Share this article

Do you have a similar project?

Our experts are there to support you in your cloud and infrastructure projects.