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
sudoprivileges on every node. - Full network connectivity between the nodes, and a unique hostname, MAC address and
product_uuidon 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:
| Port | Protocol | Used by | Nodes |
|---|---|---|---|
| 6443 | TCP | Kubernetes API server | Control plane |
| 2379-2380 | TCP | etcd | Control plane |
| 10250 | TCP | kubelet API | All |
| 10257, 10259 | TCP | controller manager, scheduler | Control plane |
| 8472 | UDP | Flannel VXLAN | All |
| 30000-32767 | TCP | NodePort services | Workers |
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
Warning
admin.confgrants full administrator access to the cluster. Keep it private and never commit it to a repository.
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
NoteFlannel uses the interface that holds the default route. If your nodes talk to each other over a second (private) interface, download
kube-flannel.ymlfirst, add- --iface=your_private_interfaceto theargsof thekube-flannelcontainer, and apply the edited file instead.
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.
