kubeadm is the upstream tool for bootstrapping a conformant Kubernetes cluster. It sets up the control plane components (API server, scheduler, controller manager and etcd) and generates the certificates and tokens that worker nodes need to join. In this tutorial you will build a cluster with one control plane node and one or more worker nodes on Ubuntu 24.04, using containerd as the container runtime and Flannel as the pod network.

Prerequisites

To follow this tutorial you need:

  • Two or more servers running Ubuntu 24.04 LTS, for example CubePath VPS instances: one will be the control plane, the rest will be workers.
  • At least 2 vCPUs and 2 GB of RAM on the control plane node, and 2 GB of RAM on each worker. kubeadm refuses to initialize a control plane with fewer than 2 CPUs.
  • A non-root user with sudo privileges on every node.
  • Full network connectivity between the nodes, and a unique hostname, MAC address and product_uuid on each one. Cloned VMs sometimes share these values.

Throughout the guide, replace control_plane_ip with the IP address the control plane will advertise to the other nodes. If your servers have a private network, use the private IP.

Steps 1 to 4 must be run on every node. Step 5 and later say explicitly which node to use.

Step 1 - Preparing the nodes

The kubelet refuses to start by default if swap is enabled, so turn it off now and remove it from /etc/fstab so it stays off after a reboot:

sudo swapoff -a
sudo sed -i '/\sswap\s/ s/^/#/' /etc/fstab

Confirm that no swap is active. The Swap line must show zeros:

free -h
               total        used        free      shared  buff/cache   available
Mem:           3.8Gi       412Mi       2.9Gi       1.0Mi       735Mi       3.4Gi
Swap:             0B          0B          0B

Kubernetes networking needs the overlay and br_netfilter kernel modules. Load them now and configure them to load at boot:

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

Enable IP forwarding and let iptables see bridged traffic. These settings are required for pods on different nodes to reach each other:

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 that the values are applied:

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

Step 2 - Configuring the firewall

The nodes use several ports to talk to each other:

PortProtocolUsed byNodes
6443TCPKubernetes API serverControl plane
2379-2380TCPetcdControl plane
10250TCPkubelet APIAll
10257, 10259TCPcontroller manager, schedulerControl plane
8472UDPFlannel VXLANAll
30000-32767TCPNodePort servicesWorkers

The simplest safe policy is to trust traffic between the cluster nodes and keep everything else closed. On each node, allow SSH and then allow all traffic from every other node in the cluster (repeat the second command once per node IP):

sudo ufw allow OpenSSH
sudo ufw allow from other_node_ip

If you want to reach the Kubernetes API from your workstation, also open port 6443 on the control plane, ideally only from your own IP:

sudo ufw allow from your_workstation_ip to any port 6443 proto tcp

Enable the firewall and check the rules:

sudo ufw enable
sudo ufw status

Step 3 - Installing containerd

Kubernetes needs a CRI-compatible container runtime. Ubuntu 24.04 ships containerd in its main archive, which is enough for kubeadm:

sudo apt update
sudo apt install -y containerd

Generate the default configuration file:

sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml > /dev/null

Ubuntu 24.04 uses cgroup v2 managed by systemd, and the kubelet uses the systemd cgroup driver by default. containerd must use the same driver, otherwise pods restart in a loop. Switch the runc option SystemdCgroup to true:

sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml

Confirm the change and restart containerd:

grep SystemdCgroup /etc/containerd/config.toml
sudo systemctl restart containerd
            SystemdCgroup = true

Check that the service is running:

systemctl status containerd --no-pager

The output must include Active: active (running).

Step 4 - Installing kubeadm, kubelet and kubectl

Kubernetes packages are published at pkgs.k8s.io, with one repository per minor version. This guide uses v1.35. Check the current stable release at kubernetes.io/releases and replace the version in both commands below if a newer one is available.

Install the packages needed to add the repository:

sudo apt install -y apt-transport-https ca-certificates curl gpg

Download the repository signing key into /etc/apt/keyrings:

sudo mkdir -p -m 755 /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.35/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

Add the repository:

echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.35/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list

Install the three components and pin them, so a routine apt upgrade does not upgrade Kubernetes behind your back. Cluster upgrades must be done with kubeadm upgrade, one minor version at a time:

sudo apt update
sudo apt install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl

Enable the kubelet. It will restart every few seconds until kubeadm init or kubeadm join gives it a configuration; that is expected at this point:

sudo systemctl enable --now kubelet

Verify the installed version:

kubeadm version -o short
v1.35.3

Step 5 - Initializing the control plane

Run this step only on the control plane node.

Pull the control plane images first. This makes kubeadm init faster and shows registry problems early:

sudo kubeadm config images pull

Initialize the cluster. --pod-network-cidr must match the network that Flannel uses by default (10.244.0.0/16), and --apiserver-advertise-address sets the IP that the other nodes will connect to:

sudo kubeadm init \
  --apiserver-advertise-address=control_plane_ip \
  --pod-network-cidr=10.244.0.0/16

After a minute or two the command finishes with a message like this:

Your Kubernetes control-plane has initialized successfully!

To start using your cluster, you need to run the following as a 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
...
Then you can join any number of worker nodes by running the following on each as root:

kubeadm join 10.0.0.10:6443 --token abcdef.0123456789abcdef \
	--discovery-token-ca-cert-hash sha256:1234...cdef

Copy the kubeadm join command somewhere safe; you will need it in Step 7.

Configure kubectl for your user by copying the admin kubeconfig:

mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

Check the node:

kubectl get nodes
NAME            STATUS     ROLES           AGE   VERSION
control-plane   NotReady   control-plane   1m    v1.35.3

The node is NotReady because there is no pod network yet.

Step 6 - Installing the Flannel pod network

A CNI plugin gives every pod an IP address and routes traffic between pods on different nodes. Flannel is simple and works well for small and medium clusters. Install it from its official release manifest on the control plane node:

kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml

Wait until the Flannel pods are running:

kubectl get pods -n kube-flannel
NAME                    READY   STATUS    RESTARTS   AGE
kube-flannel-ds-7xk2p   1/1     Running   0          45s

The node now becomes Ready, and the CoreDNS pods in kube-system start:

kubectl get nodes
kubectl get pods -n kube-system

Step 7 - Joining the worker nodes

Run the kubeadm join command from Step 5 on each worker node, with sudo:

sudo kubeadm join control_plane_ip:6443 --token your_token \
  --discovery-token-ca-cert-hash sha256:your_hash
This node has joined the cluster:
* Certificate signing request was sent to apiserver and a response was received.
* The Kubelet was informed of the new secure connection details.

Join tokens expire after 24 hours. If yours has expired, or you lost the command, generate a new one on the control plane:

sudo kubeadm token create --print-join-command

Back on the control plane, check that all nodes are Ready. New workers take about a minute while Flannel starts on them:

kubectl get nodes -o wide
NAME            STATUS   ROLES           AGE   VERSION   INTERNAL-IP
control-plane   Ready    control-plane   12m   v1.35.3   10.0.0.10
worker-1        Ready    <none>          2m    v1.35.3   10.0.0.11
worker-2        Ready    <none>          1m    v1.35.3   10.0.0.12

Step 8 - Testing the cluster

Deploy two Nginx replicas and expose them with a NodePort service to confirm that scheduling, pod networking and service routing all work:

kubectl create deployment nginx --image=nginx --replicas=2
kubectl expose deployment nginx --port=80 --type=NodePort

Check where the pods were scheduled and which port was assigned:

kubectl get pods -o wide
kubectl get service nginx
NAME    TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
nginx   NodePort   10.96.141.22   <none>        80:31234/TCP   20s

Send a request to any worker node on the NodePort (here 31234):

curl -I http://worker_node_ip:31234
HTTP/1.1 200 OK
Server: nginx/1.29.1

Test cluster DNS from a temporary pod. The service name must resolve to its ClusterIP:

kubectl run dns-test --rm -it --image=busybox:1.36 --restart=Never -- nslookup nginx.default.svc.cluster.local

When everything works, remove the test resources:

kubectl delete service nginx
kubectl delete deployment nginx

Troubleshooting

Pods on different nodes cannot reach each other, or CoreDNS stays in ContainerCreating. Check that UDP port 8472 is allowed between the nodes and that Flannel uses the right interface (see the note in Step 6). Inspect the Flannel logs with kubectl logs -n kube-flannel -l app=flannel.

Pods keep restarting and kubectl get pods -n kube-system shows CrashLoopBackOff for control plane components. This is almost always a cgroup driver mismatch. Confirm that SystemdCgroup = true is set in /etc/containerd/config.toml and restart containerd.

kubeadm init fails a preflight check. Read the message: typical causes are swap still enabled, fewer than 2 CPUs, or /proc/sys/net/ipv4/ip_forward not set to 1. Fix the cause rather than skipping the check with --ignore-preflight-errors.

The kubelet does not start. Read its log with sudo journalctl -u kubelet -e.

You need to start over on a node. kubeadm reset removes what kubeadm init or join created. It does not clean CNI configuration or iptables rules, so remove those too before rejoining:

sudo kubeadm reset -f
sudo rm -rf /etc/cni/net.d $HOME/.kube/config

Conclusion

You now have a working Kubernetes cluster built with kubeadm, containerd and Flannel, with worker nodes joined and verified by a test deployment. The packages are pinned, so upgrades happen only when you run kubeadm upgrade deliberately.

As next steps, learn the core objects in Kubernetes Pods, Services and Deployments, expose HTTP applications with an Ingress controller, and add persistent storage with a StorageClass before running stateful workloads.