| Tested on | Rocky Linux 10.2 (Red Quartz) |
|---|---|
| Package | containerd 2.2.5-1.el10_2kubeadm 1.36.3-150500.1.1kubelet 1.36.3-150500.1.1kubectl 1.36.3-150500.1.1cri-tools 1.36.0-150500.1.1Calico 3.32.1runc 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:
- Plan cluster requirements and topology
- Prepare all nodes (hostname, clock synchronization, swap, kernel modules, sysctl, and host networking)
- Install and configure containerd
- Install kubeadm, kubelet, and kubectl
- Run
kubeadm initon the control-plane node - Install Calico CNI
- Join worker nodes with
kubeadm join - 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:
stat -fc %T /sys/fs/cgroup/Sample output:
cgroup2fscgroup2fs 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.
ip route show defaultip route get 192.168.56.109Sample output on the control-plane node:
192.168.56.109 dev enp0s8 src 192.168.56.108 uid 0
cacheThe 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.
cat /sys/class/dmi/id/product_uuidkubeadm 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.
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:
hostnamectl set-hostname k8s-cpOn 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:
cat >> /etc/hosts <<'EOF'
192.168.56.108 k8s-cp
192.168.56.109 worker01
192.168.56.110 worker02
EOFConfirm the short hostname resolves locally:
hostname -fSample output:
k8s-cpThe 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:
timedatectl statusSample output:
System clock synchronized: yes
NTP service: activeIf 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:
dnf install -y chronysystemctl enable --now chronydOn Ubuntu and Debian:
apt-get update && apt-get install -y chronysystemctl enable --now chronyConfirm synchronization:
chronyc trackingSample output:
Reference ID : 192.168.1.1
Leap status : Normalchronyc sourcesSample output:
MS Name/IP address Stratum Poll Reach LastRx Last sample
===============================================================================
^* time-server.example.com 3 6 377 32 +120us[ +140us] +/- 15msLeap 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:
swapoff -ased -ri '/^[^#].*[[:space:]]swap[[:space:]]/ s/^/#/' /etc/fstabVerify swap is off:
swapon --showNo output means no swap devices or files are active.
free -h | grep -i swapSample output:
Swap: 0B 0B 0BSwap 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:
modprobe overlay && modprobe br_netfiltercat > /etc/modules-load.d/k8s.conf <<'EOF'
overlay
br_netfilter
EOFConfigure sysctl parameters
Bridge netfilter and IPv4 forwarding must be enabled before you install Kubernetes with containerd:
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
EOFApply every sysctl file you just created:
sysctl --systemFor a refresher on live sysctl loads without rebooting the node, see sysctl reload without reboot.
Sample output (trimmed):
* 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 = 1The 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:
setenforce 0 && sed -i 's/^SELINUX=.*/SELINUX=permissive/' /etc/selinux/configPermissive 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:
if systemctl is-active --quiet firewalld; then systemctl disable --now firewalld; fiOn Ubuntu and Debian:
if command -v ufw >/dev/null && ufw status | grep -q '^Status: active'; then ufw disable; fiPerform 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:
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
fiThe 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:
ping -c 2 192.168.56.109Sample output:
2 packets transmitted, 2 received, 0% packet lossRepeat 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:
dnf install -y epel-releasednf install -y containerd runcOn RHEL, enable the Docker containerd.io repository and install only containerd.io:
dnf install -y dnf-plugins-corednf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repodnf remove -y containerd runcdnf install -y containerd.ioOn Fedora, do not install epel-release. If containerd --version reports 1.x from the distribution repositories, use the Docker containerd.io repository instead:
dnf install -y dnf-plugins-corednf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repodnf remove -y containerd runcdnf install -y containerd.ioIf 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:
apt-get update && apt-get install -y ca-certificates curlinstall -m 0755 -d /etc/apt/keyringsOn Ubuntu:
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.ascchmod a+r /etc/apt/keyrings/docker.ascecho "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.listOn Debian:
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.ascchmod a+r /etc/apt/keyrings/docker.ascecho "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.listRemove conflicting distribution packages before installing containerd.io:
apt-get remove -y containerd runcapt-get update && apt-get install -y containerd.ioConfirm the installed containerd major version before editing configuration:
containerd --versionSample output on Rocky Linux 10:
containerd github.com/containerd/containerd/v2 2.2.5Do 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:
systemctl enable --now containerd && systemctl is-active containerdSample output:
activeGenerate configuration
Generate a known configuration baseline so the CRI and cgroup settings are explicit. Back up an existing package-provided file before replacing it:
test -f /etc/containerd/config.toml && cp /etc/containerd/config.toml /etc/containerd/config.toml.bakmkdir -p /etc/containerd && containerd config default > /etc/containerd/config.tomlcontainerd 2.x uses version = 3 at the top of /etc/containerd/config.toml. Inspect the file before you edit it:
head -5 /etc/containerd/config.tomlversion = 3 confirms the containerd 2.x configuration layout.
grep -n 'SystemdCgroup' /etc/containerd/config.tomlNo SystemdCgroup output means the key must be added inside the existing runc options table.
grep -n '^disabled_plugins' /etc/containerd/config.tomlNo 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:
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.tomlIf 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:
[plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options]
SystemdCgroup = trueConfirm the key is present in the file:
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:
systemctl restart containerdcontainerd config dump | grep -i SystemdCgroupSample output:
SystemdCgroup = trueThe 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:
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
EOFOn Ubuntu and Debian, create the keyring directory, then add the APT repo (adjust the minor version path when you pin a different release):
install -m 0755 -d /etc/apt/keyringsapt-get install -y apt-transport-https ca-certificates curl gpgcurl -fsSL https://pkgs.k8s.io/core:/stable:/v1.36/deb/Release.key | gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpgecho '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.listapt-get updateInstall packages
On systems using DNF5, including the tested Rocky Linux 10 nodes, override the repository exclude line for this install:
dnf install -y kubelet kubeadm kubectl cri-tools --setopt=disable_excludes=kubernetesOn systems using DNF4, including many RHEL-family 9 releases:
dnf install -y kubelet kubeadm kubectl cri-tools --disableexcludes=kubernetesOn Ubuntu and Debian:
apt-get install -y kubelet kubeadm kubectl cri-toolsHold automatic upgrades on Debian-family nodes until you plan a cluster upgrade:
apt-mark hold kubelet kubeadm kubectlPin 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:
echo 'KUBELET_EXTRA_ARGS=--node-ip=192.168.56.108' > /etc/sysconfig/kubeletOn Ubuntu and Debian:
echo 'KUBELET_EXTRA_ARGS=--node-ip=192.168.56.108' > /etc/default/kubeletUse 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:
systemctl enable --now kubeletsystemctl status kubelet --no-pagerThe 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:
kubeadm version -o short && kubectl version --client=true && kubelet --versionSample output:
v1.36.3
Client Version: v1.36.3
Kustomize Version: v5.8.1
Kubernetes v1.36.3Confirm CRI using crictl
Point crictl at the containerd socket:
cat > /etc/crictl.yaml <<'EOF'
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
EOFQuery the runtime API:
crictl versionSample output:
Version: 0.1.0
RuntimeName: containerd
RuntimeVersion: 2.2.5
RuntimeApiVersion: v1RuntimeApiVersion: 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:
env | grep -i proxyNo 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:
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:
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXYPre-pull images to surface registry problems early:
kubeadm config images pull --cri-socket unix:///run/containerd/containerd.sockSample output (trimmed):
[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:
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.
--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:
Your Kubernetes control-plane has initialized successfully!
kubeadm join 192.168.56.108:6443 --token 1tc1ap.fvqds9wnvheeksgm \
--discovery-token-ca-cert-hash sha256:359730c9d6df38487db5499627b06cbbeea3fad7b53df4daa28bd157c878c79bConfigure kubectl on the control-plane node so you can run the Calico and verification steps from this host:
mkdir -p "$HOME/.kube"cp -i /etc/kubernetes/admin.conf "$HOME/.kube/config"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:
kubectl get nodesSample output right after init (before CNI):
NAME STATUS ROLES AGE VERSION
k8s-cp NotReady control-plane 30s v1.36.3NotReady 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:
cd /tmpcurl -fsSLO https://raw.githubusercontent.com/projectcalico/calico/v3.32.1/manifests/v1_crd_projectcalico_org.yamlcurl -fsSLO https://raw.githubusercontent.com/projectcalico/calico/v3.32.1/manifests/tigera-operator.yamlIf curl reports an SSL certificate problem on your lab host, add -k only for these manifest downloads.
kubectl create -f /tmp/v1_crd_projectcalico_org.yamlkubectl create -f /tmp/tigera-operator.yamlDownload the custom resources manifest and confirm the pool CIDR matches your --pod-network-cidr:
curl -fsSLO https://raw.githubusercontent.com/projectcalico/calico/v3.32.1/manifests/custom-resources.yamlgrep -n 'cidr:\|encapsulation:' custom-resources.yamlSample output:
13: cidr: 192.168.0.0/16
14: encapsulation: VXLANCrossSubnetEdit 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:
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:
kubectl create -f custom-resources.yamlMonitor the operator until Calico reports healthy:
kubectl get tigerastatus --watchPress Ctrl+C when every row shows AVAILABLE True and DEGRADED False. Sample healthy state:
NAME AVAILABLE PROGRESSING DEGRADED
apiserver True False False
calico True False False
goldmane True False False
ippools True False False
whisker True False FalseWith the operator install, Calico workloads run mainly in calico-system:
kubectl get pods -n calico-systemSample output (trimmed):
NAME READY STATUS RESTARTS AGE
calico-node-xxxxx 1/1 Running 0 3m
calico-kube-controllers-xxxxx 1/1 Running 0 3mOn 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:
nmcli device status | grep -E 'cali|tunl|vxlan'Sample output for the operator manifest with VXLANCrossSubnet:
vxlan.calico vxlan unmanaged --Then check node readiness from the control-plane node:
kubectl get nodesSample output:
NAME STATUS ROLES AGE VERSION
k8s-cp Ready control-plane 5m v1.36.3Ready 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.
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:
systemctl is-active containerdInstall nc if it is not already present. On Rocky Linux and other RPM-family systems, the sample output below comes from nmap-ncat:
dnf install -y nmap-ncatOn Ubuntu and Debian:
apt-get install -y netcat-openbsdTest API reachability on port 6443 from the worker:
nc -zv 192.168.56.108 6443Sample 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:
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXYkubeadm join 192.168.56.108:6443 --token 1tc1ap.fvqds9wnvheeksgm --discovery-token-ca-cert-hash sha256:359730c9d6df38487db5499627b06cbbeea3fad7b53df4daa28bd157c878c79bSample success message:
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:
journalctl -u kubelet -n 30 --no-pagerWatch the node appear from the control-plane:
kubectl get nodesSample output with one worker joined:
NAME STATUS ROLES AGE VERSION
k8s-cp Ready control-plane 12m v1.36.3
worker01 Ready <none> 2m v1.36.3On nodes that use NetworkManager, repeat the interface check locally on the worker you just joined:
nmcli device status | grep -E 'cali|tunl|vxlan'Sample 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:
kubeadm token create --print-join-commandSample output:
kubeadm join 192.168.56.108:6443 --token r8kx1o.y6o933n6ylhepskp --discovery-token-ca-cert-hash sha256:359730c9d6df38487db5499627b06cbbeea3fad7b53df4daa28bd157c878c79bRun 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:
kubectl get nodes -o wideSample 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:
kubectl get pods -n calico-system -o widekubectl get pods -n kube-system -o wideEvery 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:
kubectl create deployment nginx-demo --image=nginx:1.28.3-alpine --replicas=2 && kubectl expose deployment nginx-demo --port=80 --type=ClusterIPWait until the Deployment reports a successful rollout:
kubectl rollout status deployment/nginx-demo --timeout=120sSample output:
deployment "nginx-demo" successfully rolled outCheck where the scheduler placed the replicas. Kubernetes may place both on the same worker unless you add placement constraints:
kubectl get pods -l app=nginx-demo -o wideSample 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 worker01Confirm the Service has ready endpoints before you test DNS or HTTP:
kubectl get endpointslices -l kubernetes.io/service-name=nginx-demoSample output:
NAME ADDRESSTYPE PORTS ENDPOINTS
nginx-demo-xxxxx IPv4 80 192.168.5.4,192.168.5.5With 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:
kubectl apply -f https://k8s.io/examples/admin/dns/dnsutils.yamlkubectl wait --for=condition=Ready pod/dnsutils --timeout=120sSample output:
pod/dnsutils condition metResolve the Service DNS name with the official DNS utility Pod (trailing dot avoids search-domain suffix issues):
kubectl exec dnsutils -- nslookup nginx-demo.default.svc.cluster.local.Sample output:
Server: 10.96.0.10
Address: 10.96.0.10#53
Name: nginx-demo.default.svc.cluster.local
Address: 10.101.184.198Test HTTP service routing with a separate client Pod:
kubectl run curl-test --image=curlimages/curl:8.11.1 --restart=Never --command -- sleep 3600kubectl wait --for=condition=Ready pod/curl-test --timeout=120skubectl exec curl-test -- curl -s http://nginx-demo.default.svc.cluster.local. | head -3Sample 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:
kubectl delete deployment nginx-demokubectl delete service nginx-demokubectl delete -f https://k8s.io/examples/admin/dns/dnsutils.yamlkubectl delete pod curl-testThe 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:
kubectl get nodes -o wide && kubectl get pods -A -o wide && kubectl describe node <node-name>On the affected node:
journalctl -u kubelet -n 100 --no-pager && crictl ps -a && crictl podsRun 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
- Installing kubeadm
- Creating a cluster with kubeadm
- Container runtimes
- Calico installation for self-managed on-premises Kubernetes
- Calico Kubernetes system requirements
- containerd documentation
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.

