Install Kubernetes with kubeadm on Linux (Multi-Node Cluster)

Install a multi-node Kubernetes cluster with kubeadm, containerd, and Calico on Linux, with commands for RHEL-family, Ubuntu, and Debian systems.

Published

Updated

Read time 25 min read

Reviewed byDeepak Prasad

Kubernetes multi-node cluster installed with kubeadm and containerd
Tested on Rocky Linux 10.2 (Red Quartz)
Package containerd 2.2.5-1.el10_2
kubeadm 1.36.3-150500.1.1
kubelet 1.36.3-150500.1.1
kubectl 1.36.3-150500.1.1
cri-tools 1.36.0-150500.1.1
Calico 3.32.1
runc 1.4.1-1.el10_2
Applies to Rocky Linux, RHEL, AlmaLinux, CentOS Stream, Fedora, Ubuntu, Debian
Cert prep CKA · CKS
Lab environment 1 control-plane + 1 worker tested (2 workers recommended); 8 GiB RAM and 2 vCPU per VM; topology in Step 1 below
Privilege root access on every cluster node; sudo-capable users must adapt privileged commands
Scope End-to-end kubeadm multi-node cluster setup with containerd and Calico on Linux—node prep, control-plane init, worker join, and verification. Workstation kubectl install and kubeconfig management are covered in install kubectl and configure kubeconfig. Does not cover managed clouds (EKS/GKE), single-node kind/minikube paths, or in-place major-version upgrades.
Related guides Kubernetes architecture
Add, remove and rejoin cluster nodes
Kubernetes DNS troubleshooting
Check Kubernetes cluster version

In this article, I walk you through building a multi-node Kubernetes cluster on Linux with kubeadm. You prepare each node, install containerd and the Kubernetes packages, initialize the control plane, add Calico for pod networking, join worker nodes, and run checks to confirm the cluster is healthy.

Start with Step 1 to plan hostnames and IP addresses. The same preparation runs on every node; only the control-plane host runs kubeadm init, and workers use the join command kubeadm prints. Nodes stay NotReady until a CNI is installed, so add Calico before you expect pods to schedule. If you want to manage the cluster from another Linux machine, see install kubectl and configure kubeconfig once the cluster is running.

All host-level commands in this guide are run as the root user on each Kubernetes node. If you are logged in with a non-root administrative account, run the equivalent commands with sudo according to your distribution. Commands that write files under /etc, use shell redirection, or contain heredocs may require sudo tee rather than simply adding sudo at the beginning.

Walkthrough order:

  1. Plan cluster requirements and topology
  2. Prepare all nodes (hostname, clock synchronization, swap, kernel modules, sysctl, and host networking)
  3. Install and configure containerd
  4. Install kubeadm, kubelet, and kubectl
  5. Run kubeadm init on the control-plane node
  6. Install Calico CNI
  7. Join worker nodes with kubeadm join
  8. Verify nodes, system pods, DNS, and a sample application

Step 1: Plan Kubernetes cluster requirements and topology

Plan hostnames, stable IPs, and enough CPU and RAM before you install kubeadm on Linux. Step 1 is planning only—no packages are installed yet. A three-node layout (one control-plane, two workers) is the usual CKA lab shape; I tested steps 2–8 on two VMs and repeated the join command for a second worker using the same token workflow. Each lab VM below uses 8 GiB RAM and 2 vCPUs. On a single workstation or hypervisor, budget 8 GB host RAM minimum for a one control-plane plus one worker lab, and 16 GB or more when you run three nodes at once—the same guidance as the CKAD and Kubernetes tutorial hubs.

Hostname Role IP address vCPU RAM
k8s-cp control-plane 192.168.56.108 2 8 GiB
worker01 worker 192.168.56.109 2 8 GiB
worker02 worker (recommended) 192.168.56.110 2 8 GiB

Network and version requirements for this cluster:

Requirement Value used in this guide
Per-node RAM 8 GiB minimum (lab VMs used 8 GiB each)
Host workstation RAM 8 GB minimum for 2 VMs; 16 GB+ recommended for 3 VMs on one machine
Kubernetes version v1.36.x from pkgs.k8s.io
Container runtime containerd 2.x exposing CRI v1, with SystemdCgroup = true
Host cgroup mode cgroup v2 (cgroup2fs on /sys/fs/cgroup)
Pod network CNI Calico v3.32.x (operator install)
Calico encapsulation VXLANCrossSubnet from the downloaded operator manifest
Pod CIDR 192.168.0.0/16 (must match Calico custom-resources.yaml)
Service CIDR 10.96.0.0/12 (kubeadm default)
Node connectivity All nodes reach each other on the control-plane IP and API port 6443

The Pod CIDR must not overlap the node network, Service CIDR, VPN networks, or any other routed network reachable from the nodes.

Open these ports on control-plane nodes (for external firewalls or VM security groups; active host firewall managers are disabled in Step 2 when present):

Port Protocol Purpose
6443 TCP Kubernetes API server
2379-2380 TCP etcd server client API
10250 TCP kubelet API
10257 TCP kube-controller-manager
10259 TCP kube-scheduler

Open these ports on worker nodes:

Port Protocol Purpose
10250 TCP kubelet API
10256 TCP kube-proxy health endpoint
30000-32767 TCP and UDP NodePort Services

Calico adds overlay traffic on top of node reachability. Calico v3.32.1’s operator custom-resources.yaml uses VXLANCrossSubnet. Allow UDP 4789 between nodes when VXLAN encapsulation is required. If you change the pool to IPIP, allow IP protocol 4 instead; BGP-based configurations may additionally require TCP 179.

Before you install packages, confirm the node uses cgroup v2, that cluster traffic uses the node IP you expect, and that cloned VMs do not share a machine UUID:

bash
stat -fc %T /sys/fs/cgroup/

Sample output:

output
cgroup2fs

cgroup2fs confirms that the node uses cgroup v2. Kubernetes 1.36 defaults to rejecting nodes that use cgroup v1, so all nodes must return cgroup2fs before you continue. If the command returns tmpfs, enable cgroup v2 or use a newer distribution release.

bash
ip route show default
bash
ip route get 192.168.56.109

Sample output on the control-plane node:

output
192.168.56.109 dev enp0s8 src 192.168.56.108 uid 0
    cache

The src address should be the node IP you want Kubernetes and Calico to use. My lab VMs have a host-only adapter and a NAT interface; this check confirms traffic to worker IPs leaves through 192.168.56.x, not the NAT adapter.

bash
cat /sys/class/dmi/id/product_uuid

kubeadm chooses node addresses from interfaces associated with a default route. Multiple default routes can make Kubernetes select an unexpected node IP. Calico can also autodetect a different interface unless you pin the node IP in Step 4 and Step 6. Cloned VMs must each have a unique product UUID.

Pick one stable control-plane IP for --apiserver-advertise-address and for worker kubeadm join targets. I use the host-only address 192.168.56.108 so the API survives DHCP changes on a NAT interface.

IMPORTANT
You do not need to reboot to finish this install. I turned off swap, loaded the kernel modules, and applied sysctl on both lab nodes without a restart, and the cluster still came up cleanly. Reboot only after a full OS update installs a new kernel. Bring every node back on that kernel before you run kubeadm init or kubeadm join.

Step 2: Prepare all Kubernetes nodes

Step 2 runs on the control-plane node and every worker before you install containerd or Kubernetes packages. Replace hostnames and IPs with your lab values.

Configure hostnames and name resolution

Set a static hostname on each machine with hostnamectl:

bash
hostnamectl set-hostname k8s-cp

On worker nodes, use worker01, worker02, and so on. Map every cluster IP to its hostname in /etc/hosts on all nodes when you do not run a DNS server for the lab network. Check the file first when you repeat the lab so the same mappings are not appended more than once:

bash
cat >> /etc/hosts <<'EOF'
192.168.56.108 k8s-cp
192.168.56.109 worker01
192.168.56.110 worker02
EOF

Confirm the short hostname resolves locally:

bash
hostname -f

Sample output:

output
k8s-cp

The short hostname matches what kubelet will register with the API. With these /etc/hosts entries, hostname -f returns the short name, not a DNS FQDN, which is acceptable for this lab.

Verify time synchronization

Kubernetes certificates and bootstrap operations depend on accurate system clocks. The nodes may use different display timezones, but their clocks must represent the same current time.

Use the timedatectl command to confirm clock and NTP state on every node:

bash
timedatectl status

Sample output:

output
System clock synchronized: yes
              NTP service: active

If synchronization is not active, install and start chrony using the commands for your distribution. Isolated lab networks without outbound NTP may need a chrony NTP server and client instead of the short install below.

On RHEL-family and Fedora systems, install chrony with dnf command:

bash
dnf install -y chrony
bash
systemctl enable --now chronyd

On Ubuntu and Debian:

bash
apt-get update && apt-get install -y chrony
bash
systemctl enable --now chrony

Confirm synchronization:

bash
chronyc tracking

Sample output:

output
Reference ID    : 192.168.1.1
Leap status     : Normal
bash
chronyc sources

Sample output:

output
MS Name/IP address         Stratum Poll Reach LastRx Last sample
===============================================================================
^* time-server.example.com       3   6   377    32   +120us[ +140us] +/- 15ms

Leap status: Normal and ^* beside a source indicate that chrony has selected a time source.

Disable swap

By default, kubelet fails to start when it detects active swap. This guide follows the standard kubeadm path and disables swap:

bash
swapoff -a
bash
sed -ri '/^[^#].*[[:space:]]swap[[:space:]]/ s/^/#/' /etc/fstab

Verify swap is off:

bash
swapon --show

No output means no swap devices or files are active.

bash
free -h | grep -i swap

Sample output:

output
Swap:            0B          0B          0B

Swap stays disabled across reboots because /etc/fstab no longer activates it. You do not need to reboot for this step.

Load kernel modules

Kubernetes networking needs overlay and br_netfilter. Load them now and persist across reboots:

bash
modprobe overlay && modprobe br_netfilter
bash
cat > /etc/modules-load.d/k8s.conf <<'EOF'
overlay
br_netfilter
EOF

Configure sysctl parameters

Bridge netfilter and IPv4 forwarding must be enabled before you install Kubernetes with containerd:

bash
cat > /etc/sysctl.d/99-kubernetes.conf <<'EOF'
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF

Apply every sysctl file you just created:

bash
sysctl --system

For a refresher on live sysctl loads without rebooting the node, see sysctl reload without reboot.

Sample output (trimmed):

output
* Applying /etc/sysctl.d/99-kubernetes.conf ...
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1

The bridge sysctl keys confirm pod traffic can pass through netfilter. IPv4 forwarding lets the node route traffic between Pod networks.

Configure SELinux, host firewalls, and NetworkManager

On RHEL-family and Fedora nodes using SELinux, follow the current kubeadm baseline and set SELinux to permissive mode. See disable SELinux for the full permissive versus enforcing workflow:

bash
setenforce 0 && sed -i 's/^SELINUX=.*/SELINUX=permissive/' /etc/selinux/config

Permissive mode takes effect immediately; no reboot is required. Skip this block on Ubuntu and Debian nodes without SELinux.

Calico manages the packet-filtering rules used for Pod networking. A host firewall manager can rewrite those rules. If firewalld is installed and active on a RHEL-family or Fedora node, disable it for this Calico lab:

bash
if systemctl is-active --quiet firewalld; then systemctl disable --now firewalld; fi

On Ubuntu and Debian:

bash
if command -v ufw >/dev/null && ufw status | grep -q '^Status: active'; then ufw disable; fi

Perform the NetworkManager step only when the node uses NetworkManager. Skip it when nmcli is not installed—the nmcli command shows how to verify device state after Calico starts:

bash
if command -v nmcli >/dev/null; then
    mkdir -p /etc/NetworkManager/conf.d
    cat > /etc/NetworkManager/conf.d/calico.conf <<'EOF'
[keyfile]
unmanaged-devices=interface-name:cali*;interface-name:tunl*;interface-name:vxlan.calico;interface-name:vxlan-v6.calico;interface-name:wireguard.cali;interface-name:wg-v6.cali
EOF
    systemctl restart NetworkManager
fi

The unmanaged-device setting matches Calico documentation. Verification moves to Step 6 after Calico creates its interfaces.

Verify connectivity between nodes

From the control-plane node, use ping to reach each worker IP before you install packages:

bash
ping -c 2 192.168.56.109

Sample output:

output
2 packets transmitted, 2 received, 0% packet loss

Repeat from each worker toward 192.168.56.108. Fix routing or external firewall rules before continuing—kubeadm join and Calico both assume L3 reachability between node IPs.


Step 3: Install and configure containerd

Step 3 installs containerd 2.x as the CRI on every cluster node. This guide requires cgroup v2. Kubernetes 1.36 defaults to rejecting nodes that use cgroup v1, so all nodes in this cluster must return cgroup2fs before you continue. Kubernetes 1.35 was the final release supporting containerd 1.x; Kubernetes 1.36 requires containerd 2.0 or later. Docker Engine requires a separate CRI adapter such as cri-dockerd and is outside this walkthrough.

Install and start containerd

Runc is required. Without it, kubelet logs show runc: executable file not found in $PATH and static pods crash. The containerd.io package bundles runc. When you install containerd.io, do not install a separate runc package.

On Rocky Linux, AlmaLinux, and CentOS Stream systems where the distribution repositories provide containerd 2.x, use the distro packages:

bash
dnf install -y epel-release
bash
dnf install -y containerd runc

On RHEL, enable the Docker containerd.io repository and install only containerd.io:

bash
dnf install -y dnf-plugins-core
bash
dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
bash
dnf remove -y containerd runc
bash
dnf install -y containerd.io

On Fedora, do not install epel-release. If containerd --version reports 1.x from the distribution repositories, use the Docker containerd.io repository instead:

bash
dnf install -y dnf-plugins-core
bash
dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo
bash
dnf remove -y containerd runc
bash
dnf install -y containerd.io

If Fedora already ships containerd 2.x in its own repositories, you may install the distribution containerd and runc packages instead. Verify the major version before you continue.

On Ubuntu 24.04 and Debian 12, install containerd from the Docker APT repository with apt so you receive containerd 2.x rather than an older distribution package:

bash
apt-get update && apt-get install -y ca-certificates curl
bash
install -m 0755 -d /etc/apt/keyrings

On Ubuntu:

bash
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
bash
chmod a+r /etc/apt/keyrings/docker.asc
bash
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list

On Debian:

bash
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
bash
chmod a+r /etc/apt/keyrings/docker.asc
bash
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list

Remove conflicting distribution packages before installing containerd.io:

bash
apt-get remove -y containerd runc
bash
apt-get update && apt-get install -y containerd.io

Confirm the installed containerd major version before editing configuration:

bash
containerd --version

Sample output on Rocky Linux 10:

output
containerd github.com/containerd/containerd/v2 2.2.5

Do not continue with Kubernetes 1.36 if this command reports containerd 1.x. The package source may differ by distribution, but every node needs containerd 2.x.

Enable and start the daemon with systemctl:

bash
systemctl enable --now containerd && systemctl is-active containerd

Sample output:

output
active

Generate configuration

Generate a known configuration baseline so the CRI and cgroup settings are explicit. Back up an existing package-provided file before replacing it:

bash
test -f /etc/containerd/config.toml && cp /etc/containerd/config.toml /etc/containerd/config.toml.bak
bash
mkdir -p /etc/containerd && containerd config default > /etc/containerd/config.toml

containerd 2.x uses version = 3 at the top of /etc/containerd/config.toml. Inspect the file before you edit it:

bash
head -5 /etc/containerd/config.toml

version = 3 confirms the containerd 2.x configuration layout.

bash
grep -n 'SystemdCgroup' /etc/containerd/config.toml

No SystemdCgroup output means the key must be added inside the existing runc options table.

bash
grep -n '^disabled_plugins' /etc/containerd/config.toml

No disabled_plugins output is acceptable. If the line lists cri, remove cri from that list. The CRI plugin must stay enabled.

kubelet on systemd hosts expects cgroups managed by systemd.

Enable SystemdCgroup

Try the sed replacement first:

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

If grep shows no SystemdCgroup line, add SystemdCgroup = true inside the existing containerd 2.x runc options table. Do not create a second copy of an existing TOML table:

text
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options]
  SystemdCgroup = true

Confirm the key is present in the file:

bash
grep -q 'SystemdCgroup = true' /etc/containerd/config.toml || echo "SystemdCgroup was not updated; add it inside the existing runc options table"

Restart containerd and verify the effective runtime configuration:

bash
systemctl restart containerd
bash
containerd config dump | grep -i SystemdCgroup

Sample output:

output
SystemdCgroup = true

The dump confirms kubelet will use systemd-managed cgroups through containerd.


Step 4: Install kubeadm, kubelet, and kubectl

Step 4 adds the Kubernetes packages from pkgs.k8s.io on every node. Install the same trio on workers even though only the control-plane runs kubeadm init.

Configure the pkgs.k8s.io repository

Upstream Kubernetes packages moved to pkgs.k8s.io. On RPM-based RHEL-family and Fedora nodes for Kubernetes v1.36:

bash
cat > /etc/yum.repos.d/kubernetes.repo <<'EOF'
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.36/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.36/rpm/repodata/repomd.xml.key
exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
EOF

On Ubuntu and Debian, create the keyring directory, then add the APT repo (adjust the minor version path when you pin a different release):

bash
install -m 0755 -d /etc/apt/keyrings
bash
apt-get install -y apt-transport-https ca-certificates curl gpg
bash
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.36/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
bash
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.36/deb/ /' > /etc/apt/sources.list.d/kubernetes.list
bash
apt-get update

Install packages

On systems using DNF5, including the tested Rocky Linux 10 nodes, override the repository exclude line for this install:

bash
dnf install -y kubelet kubeadm kubectl cri-tools --setopt=disable_excludes=kubernetes

On systems using DNF4, including many RHEL-family 9 releases:

bash
dnf install -y kubelet kubeadm kubectl cri-tools --disableexcludes=kubernetes

On Ubuntu and Debian:

bash
apt-get install -y kubelet kubeadm kubectl cri-tools

Hold automatic upgrades on Debian-family nodes until you plan a cluster upgrade:

bash
apt-mark hold kubelet kubeadm kubectl

Pin the kubelet node IP on multi-interface hosts

When a node has more than one network adapter, set --node-ip before you enable kubelet so the registered InternalIP matches the cluster network. Use each node’s own lab IP.

On RPM-based systems:

bash
echo 'KUBELET_EXTRA_ARGS=--node-ip=192.168.56.108' > /etc/sysconfig/kubelet

On Ubuntu and Debian:

bash
echo 'KUBELET_EXTRA_ARGS=--node-ip=192.168.56.108' > /etc/default/kubelet

Use 192.168.56.109 on worker01, 192.168.56.110 on worker02, and so on. Kubeadm reads these files for user-specified kubelet arguments. The preferred fully declarative alternative is nodeRegistration.kubeletExtraArgs in a kubeadm configuration file.

Enable kubelet

Enable kubelet on every node. It may restart every few seconds until kubeadm writes its configuration:

bash
systemctl enable --now kubelet
bash
systemctl status kubelet --no-pager

The service may show activating, failed, or repeated restarts at this stage because /var/lib/kubelet/config.yaml and the kubelet kubeconfig do not exist yet. That is expected until the node is initialized or joined.

Verify component versions

Check that kubeadm, kubectl, and kubelet report the same minor release from your configured pkgs.k8s.io v1.36 channel. The Rocky Linux 10.2 lab installed v1.36.3 from that RPM repository when the channel was configured; your patch version may differ as the channel is updated:

bash
kubeadm version -o short && kubectl version --client=true && kubelet --version

Sample output:

output
v1.36.3
Client Version: v1.36.3
Kustomize Version: v5.8.1
Kubernetes v1.36.3

Confirm CRI using crictl

Point crictl at the containerd socket:

bash
cat > /etc/crictl.yaml <<'EOF'
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
EOF

Query the runtime API:

bash
crictl version

Sample output:

output
Version:  0.1.0
RuntimeName:  containerd
RuntimeVersion:  2.2.5
RuntimeApiVersion:  v1

RuntimeApiVersion: v1 confirms containerd exposes the CRI v1 API kubelet will use.


Step 5: Initialize the Kubernetes control plane

Step 5 runs kubeadm init on the control-plane node only, after every node has containerd, kubelet, and matching host preparation.

Check whether proxy variables are set before you initialize:

bash
env | grep -i proxy

No output means this shell has no proxy variables set.

In my direct-internet lab, stale https_proxy values caused the API health check to fail, so I removed them from the kubeadm shell. In a proxy-only environment, add cluster networks to NO_PROXY instead of unsetting the proxy:

text
127.0.0.1,localhost,<control-plane-ip>,<worker-ips>,<node-network>,<pod-cidr>,<service-cidr>

When you have direct registry access and no proxy is required:

bash
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY

Pre-pull images to surface registry problems early:

bash
kubeadm config images pull --cri-socket unix:///run/containerd/containerd.sock

Sample output (trimmed):

output
[config/images] Pulled registry.k8s.io/kube-apiserver:v1.36.3
[config/images] Pulled registry.k8s.io/kube-controller-manager:v1.36.3
...

Confirm again that 192.168.0.0/16 does not overlap your node network, Service CIDR, or any VPN routes before you run init.

Initialize the cluster with the stable control-plane IP and a Pod CIDR that matches Calico:

bash
kubeadm init --apiserver-advertise-address=192.168.56.108 --pod-network-cidr=192.168.0.0/16

--apiserver-advertise-address is the address the API server advertises on this control-plane node. The generated join command uses an API endpoint reachable by the workers. --pod-network-cidr must match your CNI IP pool—the Calico custom-resources.yaml in Step 6 expects 192.168.0.0/16.

NOTE
A single-control-plane cluster created without --control-plane-endpoint cannot later be converted to kubeadm high availability through the supported workflow. See configure a highly available control plane with kubeadm for the HA build path. That limitation does not affect the learning cluster built here.

On success, kubeadm prints the kubeadm join command—save it. Sample tail:

output
Your Kubernetes control-plane has initialized successfully!

kubeadm join 192.168.56.108:6443 --token 1tc1ap.fvqds9wnvheeksgm \
	--discovery-token-ca-cert-hash sha256:359730c9d6df38487db5499627b06cbbeea3fad7b53df4daa28bd157c878c79b

Configure kubectl on the control-plane node so you can run the Calico and verification steps from this host:

bash
mkdir -p "$HOME/.kube"
bash
cp -i /etc/kubernetes/admin.conf "$HOME/.kube/config"
bash
chmod 600 "$HOME/.kube/config"

These commands configure kubectl for the root account on the control-plane node (/root/.kube/config). To configure kubectl for a non-root user or a separate workstation, follow install kubectl and configure kubeconfig. The admin.conf file grants cluster-admin access and is appropriate for lab administration on the control-plane node only.

Confirm the API answers:

bash
kubectl get nodes

Sample output right after init (before CNI):

output
NAME     STATUS     ROLES           AGE   VERSION
k8s-cp   NotReady   control-plane   30s   v1.36.3

NotReady at this stage is normal—the node waits for a CNI plugin.


Step 6: Install Calico pod networking

Step 6 installs the pod network. kubeadm ships CoreDNS and kube-proxy but deliberately does not install a CNI plugin. Without Calico, kubelet reports NetworkPluginNotReady and cni plugin not initialized.

Calico recommends the operator install path for current releases. I used Calico v3.32.1 with curl. Download the manifests locally first, then apply them from disk:

bash
cd /tmp
bash
curl -fsSLO https://raw.githubusercontent.com/projectcalico/calico/v3.32.1/manifests/v1_crd_projectcalico_org.yaml
bash
curl -fsSLO https://raw.githubusercontent.com/projectcalico/calico/v3.32.1/manifests/tigera-operator.yaml

If curl reports an SSL certificate problem on your lab host, add -k only for these manifest downloads.

bash
kubectl create -f /tmp/v1_crd_projectcalico_org.yaml
bash
kubectl create -f /tmp/tigera-operator.yaml

Download the custom resources manifest and confirm the pool CIDR matches your --pod-network-cidr:

bash
curl -fsSLO https://raw.githubusercontent.com/projectcalico/calico/v3.32.1/manifests/custom-resources.yaml
bash
grep -n 'cidr:\|encapsulation:' custom-resources.yaml

Sample output:

output
13:        cidr: 192.168.0.0/16
14:        encapsulation: VXLANCrossSubnet

Edit custom-resources.yaml and add nodeAddressAutodetectionV4 under spec.calicoNetwork so Calico uses the Kubernetes node InternalIP instead of its default first-found autodetection. Keep the other resources and fields in custom-resources.yaml unchanged—you are only adding nodeAddressAutodetectionV4 under spec.calicoNetwork. The relevant block should resemble:

yaml
spec:
  calicoNetwork:
    nodeAddressAutodetectionV4:
      kubernetes: NodeInternalIP
    ipPools:
      - name: default-ipv4-ippool
        blockSize: 26
        cidr: 192.168.0.0/16
        encapsulation: VXLANCrossSubnet
        natOutgoing: Enabled
        nodeSelector: all()

Apply the custom resources:

bash
kubectl create -f custom-resources.yaml

Monitor the operator until Calico reports healthy:

bash
kubectl get tigerastatus --watch

Press Ctrl+C when every row shows AVAILABLE True and DEGRADED False. Sample healthy state:

output
NAME        AVAILABLE   PROGRESSING   DEGRADED
apiserver   True        False         False
calico      True        False         False
goldmane    True        False         False
ippools     True        False         False
whisker     True        False         False

With the operator install, Calico workloads run mainly in calico-system:

bash
kubectl get pods -n calico-system

Sample output (trimmed):

output
NAME                                       READY   STATUS    RESTARTS   AGE
calico-node-xxxxx                          1/1     Running   0          3m
calico-kube-controllers-xxxxx              1/1     Running   0          3m

On nodes that use NetworkManager, run this command on the control-plane node now. After joining each worker in Step 7, repeat it locally on that worker:

bash
nmcli device status | grep -E 'cali|tunl|vxlan'

Sample output for the operator manifest with VXLANCrossSubnet:

output
vxlan.calico  vxlan  unmanaged  --

Then check node readiness from the control-plane node:

bash
kubectl get nodes

Sample output:

output
NAME     STATUS   ROLES           AGE   VERSION
k8s-cp   Ready    control-plane   5m    v1.36.3

Ready confirms that kubelet and Calico networking are functioning on the control-plane node. You can now join the workers and verify Calico on every node.

NOTE
A manifest-only install (kubectl apply -f calico.yaml) is acceptable for a small learning lab, but Calico documents the operator workflow above as the current installation path. If you use the raw manifest, pin it to v3.32.1 and expect Calico pods in kube-system instead of calico-system.

Step 7: Join worker nodes

Step 7 runs kubeadm join on each worker after the control-plane is Ready and Calico is running. Each worker needs steps 2–4 completed first.

Verify containerd and port 6443

On the worker, confirm containerd is active:

bash
systemctl is-active containerd

Install nc if it is not already present. On Rocky Linux and other RPM-family systems, the sample output below comes from nmap-ncat:

bash
dnf install -y nmap-ncat

On Ubuntu and Debian:

bash
apt-get install -y netcat-openbsd

Test API reachability on port 6443 from the worker:

bash
nc -zv 192.168.56.108 6443

Sample output:

output
Ncat: Connected to 192.168.56.108:6443.

Connection refused here usually means a host firewall or security group blocking 6443, or a wrong API IP in the join command.

Run kubeadm join

On the worker node, check proxy settings. In my direct-internet lab I unset stale proxy variables; in a proxy-only environment, extend NO_PROXY with cluster networks instead:

bash
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY
bash
kubeadm join 192.168.56.108:6443 --token 1tc1ap.fvqds9wnvheeksgm --discovery-token-ca-cert-hash sha256:359730c9d6df38487db5499627b06cbbeea3fad7b53df4daa28bd157c878c79b

Sample success message:

output
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 to the apiserver.

If join fails, inspect kubelet on the worker:

bash
journalctl -u kubelet -n 30 --no-pager

Watch the node appear from the control-plane:

bash
kubectl get nodes

Sample output with one worker joined:

output
NAME       STATUS   ROLES           AGE     VERSION
k8s-cp     Ready    control-plane   12m     v1.36.3
worker01   Ready    <none>          2m      v1.36.3

On nodes that use NetworkManager, repeat the interface check locally on the worker you just joined:

bash
nmcli device status | grep -E 'cali|tunl|vxlan'

Sample output:

output
vxlan.calico  vxlan  unmanaged  --

Repeat kubeadm join on worker02 for a three-node Kubernetes multi-node cluster.

Generate a new join command if the token expired

Bootstrap tokens expire after 24 hours by default. On the control-plane node, create a fresh token and print the full join line:

bash
kubeadm token create --print-join-command

Sample output:

output
kubeadm join 192.168.56.108:6443 --token r8kx1o.y6o933n6ylhepskp --discovery-token-ca-cert-hash sha256:359730c9d6df38487db5499627b06cbbeea3fad7b53df4daa28bd157c878c79b

Run the new command on any worker that failed with kubeadm token expired.


Step 8: Verify the multi-node cluster

Step 8 confirms nodes, system pods, DNS, and a sample workload before you use the cluster for practice.

Check nodes and system Pods

List cluster nodes with their roles and IPs:

bash
kubectl get nodes -o wide

Sample output:

output
NAME       STATUS   ROLES           AGE   VERSION    INTERNAL-IP      ...
k8s-cp     Ready    control-plane   17m   v1.36.3    192.168.56.108   ...
worker01   Ready    <none>          17m   v1.36.3    192.168.56.109   ...

Confirm system pods are healthy:

bash
kubectl get pods -n calico-system -o wide
bash
kubectl get pods -n kube-system -o wide

Every Calico pod in calico-system should be Running with a READY count matching containers. In kube-system, the API server, scheduler, controller manager, etcd, kube-proxy, and CoreDNS Pods should be Running. Completed one-time Jobs, when present, may show Completed.

Deploy an nginx application

Create a two-replica Deployment and a ClusterIP Service with pinned image tags:

bash
kubectl create deployment nginx-demo --image=nginx:1.28.3-alpine --replicas=2 && kubectl expose deployment nginx-demo --port=80 --type=ClusterIP

Wait until the Deployment reports a successful rollout:

bash
kubectl rollout status deployment/nginx-demo --timeout=120s

Sample output:

output
deployment "nginx-demo" successfully rolled out

Check where the scheduler placed the replicas. Kubernetes may place both on the same worker unless you add placement constraints:

bash
kubectl get pods -l app=nginx-demo -o wide

Sample output:

output
NAME                          READY   STATUS    IP            NODE
nginx-demo-64bdf7866f-6774b   1/1     Running   192.168.5.5   worker01
nginx-demo-64bdf7866f-g2ftz   1/1     Running   192.168.5.4   worker01

Confirm the Service has ready endpoints before you test DNS or HTTP:

bash
kubectl get endpointslices -l kubernetes.io/service-name=nginx-demo

Sample output:

output
NAME               ADDRESSTYPE   PORTS   ENDPOINTS
nginx-demo-xxxxx   IPv4          80      192.168.5.4,192.168.5.5

With two workers, scale replicas or add a pod anti-affinity rule to see spread across nodes.

Test Kubernetes DNS and service connectivity

Apply the official DNS utility manifest and wait until the Pod is Ready:

bash
kubectl apply -f https://k8s.io/examples/admin/dns/dnsutils.yaml
bash
kubectl wait --for=condition=Ready pod/dnsutils --timeout=120s

Sample output:

output
pod/dnsutils condition met

Resolve the Service DNS name with the official DNS utility Pod (trailing dot avoids search-domain suffix issues):

bash
kubectl exec dnsutils -- nslookup nginx-demo.default.svc.cluster.local.

Sample output:

output
Server:		10.96.0.10
Address:	10.96.0.10#53

Name:	nginx-demo.default.svc.cluster.local
Address: 10.101.184.198

Test HTTP service routing with a separate client Pod:

bash
kubectl run curl-test --image=curlimages/curl:8.11.1 --restart=Never --command -- sleep 3600
bash
kubectl wait --for=condition=Ready pod/curl-test --timeout=120s
bash
kubectl exec curl-test -- curl -s http://nginx-demo.default.svc.cluster.local. | head -3

Sample output:

output
<!DOCTYPE html>
<html>
<head>

Successful DNS lookup and HTTP access confirm that CoreDNS and ClusterIP service routing are working. It proves cross-node Pod networking only when the client and nginx endpoint are running on different nodes.

Remove the test resources

Delete the temporary Deployment, Service, DNS client, and HTTP client so only cluster-system workloads remain:

bash
kubectl delete deployment nginx-demo
bash
kubectl delete service nginx-demo
bash
kubectl delete -f https://k8s.io/examples/admin/dns/dnsutils.yaml
bash
kubectl delete pod curl-test

The cluster returns to system workloads only.


Troubleshoot Common kubeadm Installation Errors

Symptom Likely cause Fix
kubeadm init failed — API server not healthy HTTP proxy env vars intercepting localhost health checks; kubelet not running; missing or broken containerd/runc; clock skew between nodes Check env | grep -i proxy; extend NO_PROXY or unset only when direct access is intended; on containerd.io systems reinstall with dnf install -y containerd.io or apt-get install -y containerd.io after removing conflicting containerd and runc packages; on distro-package systems install both containerd and runc; verify timedatectl status on all nodes; systemctl status kubelet; kubeadm reset -f and retry init
kubeadm join failed — port 10250 in use or kubelet.conf exists Previous join or partial reset on the worker kubeadm reset -f on the worker; remove /etc/cni/net.d if prompted; re-run join
kubeadm connection refused port 6443 API not listening; external firewall blocking 6443; wrong advertise address Open 6443 to workers; confirm nc -zv <cp-ip> 6443 from worker
container runtime is not running containerd stopped or misconfigured systemctl enable --now containerd; validate /etc/containerd/config.toml and containerd config dump | grep -i SystemdCgroup
CRI v1 runtime API is not implemented Wrong socket in crictl; CRI plugin disabled in containerd Point /etc/crictl.yaml at unix:///run/containerd/containerd.sock; grep '^disabled_plugins' /etc/containerd/config.toml; crictl version; journalctl -u containerd -n 50 --no-pager
Kubernetes node NotReady after kubeadm join CNI not installed yet; Calico image pull failure Complete Step 6; kubectl describe pod -n calico-system <pod-name>; pull the exact image from the pod spec with crictl pull <image:tag>
NetworkPluginNotReady / cni plugin not initialized No CNI installed Complete the Calico operator install in Step 6; wait for tigerastatus to report healthy
CoreDNS Pending after kubeadm init CNI not ready; insufficient CPU; taints on single node Install CNI first; ensure workers exist for multi-node scheduling
Calico pods not running Missing or broken containerd installation; active host firewall manager rewriting rules; NetworkManager controlling Calico interfaces when NetworkManager is installed; UDP 4789 blocked when VXLAN is required Reinstall containerd.io or verify distro containerd and runc packages; disable the active host firewall manager for this lab; verify Calico interfaces are unmanaged when NetworkManager is used; check UDP 4789 between nodes
Calico manifest download fails with SSL certificate problem Host CA bundle cannot verify GitHub Download with curl -kfsSLO into /tmp, then kubectl create -f the local files
kubeadm token expired Join attempted after 24h kubeadm token create --print-join-command on control-plane
kubeadm reset before rejoin Node contains kubelet, CNI, or bootstrap state from an earlier init or join kubeadm reset -f; rm -rf /etc/cni/net.d; systemctl restart containerd kubelet; join again
Routes or resolver change unexpectedly on a cloud VM nm-cloud-setup or a platform network agent rewriting routes or /etc/resolv.conf Disable the platform agent for the lab or move to a bare-metal or local VM image

When several symptoms overlap, collect this baseline from the control-plane node:

bash
kubectl get nodes -o wide && kubectl get pods -A -o wide && kubectl describe node <node-name>

On the affected node:

bash
journalctl -u kubelet -n 100 --no-pager && crictl ps -a && crictl pods

Run kubectl commands from the control-plane node or any host with a valid kubeconfig. Run journalctl and crictl on the node where the failure appears.


References


Summary

Plan the topology (Step 1), prepare every node (Step 2), install containerd (Step 3), install kubelet, kubeadm, and kubectl (Step 4), run kubeadm init (Step 5), install Calico with the operator (Step 6), join workers (Step 7), and verify DNS plus a sample app (Step 8). I did not reboot either lab node for that baseline path. With every node Ready and the system Pods running, you have a working multi-node Kubernetes cluster ready for practice workloads. To install kubectl on a separate workstation and manage kubeconfig contexts, follow install kubectl and configure kubeconfig. The workflow was tested on Rocky Linux 10.2, with distribution-specific package and host-preparation commands provided for other supported Linux systems.


Frequently Asked Questions

1. Do I need to reboot after installing Kubernetes with kubeadm?

You do not need to reboot for swap off, kernel modules, sysctl, SELinux permissive mode, disabling an active host firewall manager, containerd, or kubeadm packages. Reboot only after a full OS update installs a new kernel, and bring every node up on that kernel before you run kubeadm init or join workers.

2. Can I run a single control-plane node and one worker for CKA practice?

Yes for basic kubectl and workload practice. CKA scenarios that need pod spread across multiple workers, drain one worker while keeping replicas on another, or simulate a third failure domain still benefit from a second worker node.

3. Why does my node stay NotReady right after kubeadm init?

kubeadm does not install a CNI plugin. Until Calico or another CNI is running, kubelet reports NetworkPluginNotReady and cni plugin not initialized. Install a pod network that matches your --pod-network-cidr before expecting Ready status.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)