This guide walks through a full Kubernetes 1.32 cluster deployment on Rocky Linux 10 or AlmaLinux 10 using kubeadm and containerd as the container runtime. I have been running production Kubernetes clusters for over a decade now, and this is the exact process I follow when standing up new clusters from scratch. Every command in this guide has been tested on fresh Rocky Linux 10 and AlmaLinux 10 minimal installs.
Kubernetes 1.32 shipped with several stability improvements and continued the push toward a more modular, container-runtime-interface-driven architecture. With dockershim long gone, containerd is the default and recommended runtime for production clusters. Rocky Linux 10 and AlmaLinux 10 – both RHEL 10 rebuilds – give you a stable, enterprise-grade base OS with long-term support.
By the end of this guide you will have a working three-node Kubernetes cluster with Calico networking, ready to run workloads.
Prerequisites
You need at least three servers – one control plane node and two worker nodes. Virtual machines or bare metal both work fine. Here are the minimum specs for each node:
- OS: Rocky Linux 10 or AlmaLinux 10 (minimal install)
- RAM: 2 GB minimum (4 GB recommended for the control plane)
- CPU: 2 vCPUs minimum
- Disk: 20 GB free space
- Network: Full connectivity between all nodes on required ports
- Privileges: Root or sudo access on all nodes
For this guide I am using the following hostnames and IPs. Replace these with your actual values.
| Role | Hostname | IP Address |
|---|---|---|
| Control Plane | k8s-control01 | 192.168.1.10 |
| Worker Node 1 | k8s-worker01 | 192.168.1.11 |
| Worker Node 2 | k8s-worker02 | 192.168.1.12 |
Set Hostnames on Each Node
Run the appropriate command on each server to set the hostname.
On the control plane node:
sudo hostnamectl set-hostname k8s-control01
On worker node 1:
sudo hostnamectl set-hostname k8s-worker01
On worker node 2:
sudo hostnamectl set-hostname k8s-worker02
Configure /etc/hosts on All Nodes
Add these entries to /etc/hosts on every node so they can resolve each other by hostname. This is important even if you have DNS – it provides a fallback and speeds up internal resolution.
cat <<EOF | sudo tee -a /etc/hosts
192.168.1.10 k8s-control01
192.168.1.11 k8s-worker01
192.168.1.12 k8s-worker02
EOF
Verify that each node can ping the others by hostname:
ping -c 2 k8s-control01
ping -c 2 k8s-worker01
ping -c 2 k8s-worker02
Disable Swap on All Nodes
Kubernetes requires swap to be disabled. The kubelet will refuse to start if swap is active. Turn it off immediately and make the change persistent across reboots.
sudo swapoff -a
sudo sed -i '/ swap / s/^/#/' /etc/fstab
Confirm swap is off by checking that the Swap line shows all zeros:
free -h
Load Required Kernel Modules
Kubernetes networking and containerd need 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
Verify the modules loaded correctly:
lsmod | grep -E "overlay|br_netfilter"
You should see both overlay and br_netfilter in the output.
Set Required Sysctl Parameters
These kernel parameters enable bridge traffic to pass through iptables and allow IP forwarding – both are required for pod-to-pod and pod-to-external networking.
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 the parameters are set:
sysctl net.bridge.bridge-nf-call-iptables net.bridge.bridge-nf-call-ip6tables net.ipv4.ip_forward
All three values should return 1.
Step 1 – Install containerd Runtime on All Nodes
We will install containerd from the official Docker repository. This gives you a recent, well-maintained build of containerd that is tested against current Kubernetes releases. Run every command in this section on all three nodes.
First, install the yum-utils package which provides the yum-config-manager tool, then add the Docker CE repository:
sudo dnf install -y yum-utils
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
Now install the containerd.io package:
sudo dnf install -y containerd.io
Configure containerd to Use systemd Cgroup Driver
This is a step that people skip and then spend hours debugging. Kubernetes expects the container runtime to use the systemd cgroup driver on systemd-based systems like Rocky Linux and AlmaLinux. The default containerd config uses the cgroupfs driver, which causes a mismatch and leads to unstable kubelet behavior. If you want to learn more about container runtimes and how they work, check out our guide on configuring containerd as a Kubernetes runtime.
Generate the default containerd configuration file:
sudo mkdir -p /etc/containerd
sudo containerd config default | sudo tee /etc/containerd/config.toml > /dev/null
Edit the config file to enable the systemd cgroup driver. Find the [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options] section and set SystemdCgroup = true:
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
Verify the change was applied correctly:
grep SystemdCgroup /etc/containerd/config.toml
The output should show SystemdCgroup = true.
Enable and Start containerd
Enable containerd so it starts on boot, then start the service:
sudo systemctl enable --now containerd
Check the service status to confirm it is running:
sudo systemctl status containerd
You should see active (running) in the output. If containerd fails to start, check the journal logs with journalctl -u containerd -xe for configuration errors.
Step 2 – Install kubeadm, kubelet, and kubectl on All Nodes
With the container runtime ready, install the Kubernetes tools on all three nodes. Kubernetes now hosts packages at pkgs.k8s.io – the old Google-hosted repositories are deprecated and no longer receive updates.
Add the Kubernetes Repository
Create the repo file pointing to the Kubernetes 1.32 package stream:
cat <<EOF | sudo tee /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.32/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.32/rpm/repodata/repomd.xml.key
exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
EOF
Install Kubernetes Packages
Install kubeadm, kubelet, and kubectl. The --disableexcludes flag is required because we excluded these packages in the repo definition to prevent accidental upgrades during routine system updates.
sudo dnf install -y kubelet kubeadm kubectl --disableexcludes=kubernetes
Enable the kubelet service so it starts on boot. It will not fully start until the node is initialized or joined to a cluster, but enabling it now is necessary.
sudo systemctl enable kubelet
Verify the installed versions:
kubeadm version
kubelet --version
kubectl version --client
All three tools should report version 1.32.x. If you see older versions, double-check that your repo file points to v1.32 and run dnf clean all before retrying.
Step 3 – Configure Firewall Rules
Rocky Linux 10 and AlmaLinux 10 come with firewalld active by default. You need to open specific ports on the control plane and worker nodes for the cluster to function properly. Skipping this step is one of the most common reasons nodes fail to join or pods cannot communicate.
Control Plane Node Firewall Rules
Run these commands on the control plane node only. These ports handle the API server, etcd, kubelet, and scheduler traffic.
sudo firewall-cmd --permanent --add-port=6443/tcp
sudo firewall-cmd --permanent --add-port=2379-2380/tcp
sudo firewall-cmd --permanent --add-port=10250/tcp
sudo firewall-cmd --permanent --add-port=10251/tcp
sudo firewall-cmd --permanent --add-port=10252/tcp
sudo firewall-cmd --reload
Here is what each port does:
- 6443 – Kubernetes API server (used by all components)
- 2379-2380 – etcd server client and peer communication
- 10250 – Kubelet API
- 10251 – kube-scheduler
- 10252 – kube-controller-manager
Verify the rules are active:
sudo firewall-cmd --list-ports
Worker Node Firewall Rules
Run these commands on each worker node. Workers need the kubelet port and the NodePort range for exposing services.
sudo firewall-cmd --permanent --add-port=10250/tcp
sudo firewall-cmd --permanent --add-port=30000-32767/tcp
sudo firewall-cmd --reload
Confirm the firewall rules are in place:
sudo firewall-cmd --list-ports
You should see 10250/tcp and 30000-32767/tcp listed.
Step 4 – Initialize the Kubernetes Cluster
Now we initialize the cluster on the control plane node. This step only runs on k8s-control01 – do not run it on workers.
The --pod-network-cidr flag sets the IP range for pods. We are using 10.244.0.0/16 which works with Calico’s default configuration. The --apiserver-advertise-address should be set to the control plane node’s IP address.
sudo kubeadm init \
--pod-network-cidr=10.244.0.0/16 \
--apiserver-advertise-address=192.168.1.10 \
--kubernetes-version=stable-1.32
This takes a few minutes. When it completes you will see output that includes a kubeadm join command. Copy and save that command somewhere safe – you will need it to add worker nodes to the cluster. The output looks something like this:
kubeadm join 192.168.1.10:6443 --token abcdef.0123456789abcdef \
--discovery-token-ca-cert-hash sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Configure kubectl Access
Set up kubectl for your regular user account on the control plane node. This copies the admin kubeconfig into your home directory so you can run kubectl without sudo.
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
Test that kubectl works:
kubectl cluster-info
You should see the Kubernetes control plane address and CoreDNS address. At this point the control plane node shows as NotReady because we have not installed a CNI plugin yet. That is expected.
kubectl get nodes
Step 5 – Install Calico CNI Plugin
Pods cannot communicate without a Container Network Interface plugin. Calico is the most widely deployed CNI in production and provides both networking and network policy enforcement. Install it by applying the Calico operator and custom resource manifests.
First, install the Calico operator:
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.29.1/manifests/tigera-operator.yaml
Next, download the custom resources manifest so you can modify the pod CIDR to match what you specified during kubeadm init:
curl -O https://raw.githubusercontent.com/projectcalico/calico/v3.29.1/manifests/custom-resources.yaml
Edit the file and change the CIDR value to 10.244.0.0/16 to match your pod network:
sed -i 's|192.168.0.0/16|10.244.0.0/16|' custom-resources.yaml
Apply the custom resources:
kubectl create -f custom-resources.yaml
Watch the Calico pods come up in the calico-system namespace. Wait until all pods show Running status:
kubectl get pods -n calico-system -w
Once all Calico pods are running, check the node status again. The control plane node should now show Ready:
kubectl get nodes
If the node stays in NotReady state for more than a few minutes, check the Calico pod logs with kubectl logs -n calico-system -l k8s-app=calico-node to identify the issue.
Step 6 – Join Worker Nodes to the Cluster
Now switch to each worker node and run the join command that was generated during kubeadm init. Run this on k8s-worker01 and k8s-worker02.
sudo kubeadm join 192.168.1.10:6443 --token abcdef.0123456789abcdef \
--discovery-token-ca-cert-hash sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Replace the token and hash with the actual values from your kubeadm init output. If you lost the join command, you can regenerate a new token on the control plane node:
kubeadm token create --print-join-command
After running the join command on both workers, go back to the control plane node and check the cluster status. It may take a minute or two for the workers to appear as Ready while Calico sets up networking on each node.
kubectl get nodes -o wide
Expected output should look like this:
NAME STATUS ROLES AGE VERSION INTERNAL-IP OS-IMAGE
k8s-control01 Ready control-plane 10m v1.32.x 192.168.1.10 Rocky Linux 10
k8s-worker01 Ready <none> 2m v1.32.x 192.168.1.11 Rocky Linux 10
k8s-worker02 Ready <none> 2m v1.32.x 192.168.1.12 Rocky Linux 10
All three nodes should show Ready status. If any worker shows NotReady, check the troubleshooting section at the end of this guide.
Optionally, label the worker nodes with the worker role for clarity:
kubectl label node k8s-worker01 node-role.kubernetes.io/worker=worker
kubectl label node k8s-worker02 node-role.kubernetes.io/worker=worker
Verify the labels are applied:
kubectl get nodes
Step 7 – Deploy a Test Application
Let us deploy a simple nginx application to confirm the cluster is working end to end – scheduling, networking, and service discovery all need to be functional for this test to pass.
Create an nginx deployment with two replicas:
kubectl create deployment nginx-test --image=nginx:latest --replicas=2
Watch the pods come up and confirm they get scheduled across worker nodes:
kubectl get pods -o wide
You should see two nginx pods in Running state, ideally spread across both worker nodes.
Expose the deployment as a NodePort service so it is accessible from outside the cluster:
kubectl expose deployment nginx-test --type=NodePort --port=80
Find the assigned NodePort:
kubectl get svc nginx-test
The output shows the NodePort in the PORT(S) column, something like 80:31234/TCP. The number after the colon is the NodePort. Test the service by hitting any node’s IP on that port:
curl http://192.168.1.11:31234
Replace 31234 with your actual NodePort value. You should see the default nginx welcome page HTML in the response. This confirms that pod scheduling, container networking, and Kubernetes service routing are all working correctly.
Clean up the test deployment when you are done verifying:
kubectl delete deployment nginx-test
kubectl delete svc nginx-test
Step 8 – Install Kubernetes Dashboard (Optional)
The Kubernetes Dashboard provides a web-based UI for managing your cluster. It is optional but useful for teams that prefer a graphical interface. For production clusters I recommend pairing it with proper RBAC and authentication.
Deploy the dashboard using the official manifest:
kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/v2.7.0/aio/deploy/recommended.yaml
Verify that the dashboard pods are running in the kubernetes-dashboard namespace:
kubectl get pods -n kubernetes-dashboard
Create a service account and cluster role binding for admin access to the dashboard:
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ServiceAccount
metadata:
name: admin-user
namespace: kubernetes-dashboard
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: admin-user
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: ServiceAccount
name: admin-user
namespace: kubernetes-dashboard
EOF
Generate a login token for the admin user:
kubectl -n kubernetes-dashboard create token admin-user
Copy the token from the output. To access the dashboard, start the kubectl proxy on the control plane node or your workstation:
kubectl proxy
Then open the following URL in your browser and paste the token when prompted:
http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/
For production environments, consider setting up an Ingress controller instead of using kubectl proxy. You can manage your Kubernetes applications more efficiently using Helm – see our guide on installing and using Helm 3 on Kubernetes.
Step 9 – Monitoring Your Cluster (Recommended Next Steps)
A Kubernetes cluster without monitoring is flying blind. Once your cluster is up and running, I strongly recommend setting up a monitoring stack. The standard approach is Prometheus for metrics collection and Grafana for visualization. You can find detailed instructions in our guide on setting up Prometheus and Grafana on Kubernetes.
You should also consider setting up persistent storage for your workloads. For clusters running on virtual machines, NFS or a CSI driver for your hypervisor storage is a good starting point.
Troubleshooting Common Issues
After setting up hundreds of Kubernetes clusters over the years, these are the issues I see most often. If something is not working, start here before diving into deeper debugging.
Node Shows NotReady Status
This is usually a CNI or kubelet issue. Check the kubelet logs on the affected node for specific errors:
sudo journalctl -u kubelet -f
Common causes include the CNI plugin not being installed, containerd not running, or the node being unable to reach the API server. Also verify that the required firewall ports are open.
Swap Not Disabled
If the kubelet fails to start with errors about swap, verify swap is fully disabled:
sudo swapon --show
This command should produce no output. If it shows a swap partition, disable it and remove or comment out the swap entry in /etc/fstab. Some systems also have zram-based swap that you need to disable separately:
sudo systemctl disable --now zram-generator.service 2>/dev/null
sudo swapoff -a
Cgroup Driver Mismatch
If you see errors about cgroup driver mismatch in the kubelet logs, it means containerd and the kubelet are using different cgroup drivers. Confirm that SystemdCgroup = true is set in /etc/containerd/config.toml:
grep SystemdCgroup /etc/containerd/config.toml
If you had to change this setting, restart containerd and the kubelet:
sudo systemctl restart containerd
sudo systemctl restart kubelet
Firewall Ports Not Open
If worker nodes cannot join the cluster or pods cannot communicate, firewall rules are the first thing to check. List all open ports on the node in question:
sudo firewall-cmd --list-all
Compare the output against the required ports listed in Step 3 of this guide. As a quick test, you can temporarily disable the firewall to confirm it is the cause:
sudo systemctl stop firewalld
If things start working after stopping the firewall, add the missing rules and re-enable it. Do not leave firewalld disabled on production systems.
DNS Resolution Failures Inside Pods
If pods can reach external IPs but not hostnames, CoreDNS is likely having issues. Check CoreDNS pod status first:
kubectl get pods -n kube-system -l k8s-app=kube-dns
If the CoreDNS pods are in CrashLoopBackOff, check their logs:
kubectl logs -n kube-system -l k8s-app=kube-dns
A common fix is to ensure the host’s /etc/resolv.conf does not point to a loopback address (127.0.0.1 or 127.0.0.53). CoreDNS reads the host’s resolv.conf for upstream DNS resolution. If you are running systemd-resolved, configure it to use the actual DNS server IP.
Join Token Expired
Bootstrap tokens expire after 24 hours by default. If you try to join a worker and the token is rejected, create a new one on the control plane:
kubeadm token create --print-join-command
This prints a fresh join command with a new token and the correct CA certificate hash.
containerd Not Pulling Images
If pods are stuck in ImagePullBackOff or ErrImagePull, check that the node can reach container registries. Test from the problematic node:
sudo crictl pull nginx:latest
If this fails, check DNS resolution and network connectivity from the node. Corporate firewalls or proxy settings can block outgoing connections to registries like registry.k8s.io and docker.io.
Resetting the Cluster
If you need to start over – maybe you hit a configuration error during setup – you can reset a node with kubeadm. Run this on the node you want to reset:
sudo kubeadm reset -f
sudo rm -rf /etc/cni/net.d
sudo rm -rf $HOME/.kube/config
sudo iptables -F && sudo iptables -t nat -F && sudo iptables -t mangle -F && sudo iptables -X
After resetting, you can re-run kubeadm init on the control plane or kubeadm join on workers to rebuild the cluster. For more Kubernetes administration tasks, check our guide on managing Kubernetes clusters with kubectl.
Conclusion
You now have a fully functional Kubernetes 1.32 cluster running on Rocky Linux 10 or AlmaLinux 10 with containerd as the container runtime and Calico handling pod networking. The cluster is ready for deploying workloads.
From here, consider adding an Ingress controller like Nginx or Traefik for HTTP routing, setting up persistent storage with a CSI driver, and deploying a monitoring stack with Prometheus and Grafana. For multi-node production clusters, you should also look into setting up etcd backups and configuring pod security standards.
Keep your cluster updated by following the official Kubernetes version skew policy when upgrading – always upgrade the control plane first, then workers one at a time. The kubeadm upgrade command handles most of the heavy lifting for in-place version upgrades.





































































Hi! Thank you for this write-up, I used it to install my very first K8s Cluster on my homelab.
But I had two problems along the way getting it to finally work:
1) The tigera-operator didn’t spin up the calico pods. I had to additionaly run
kubectl create -f https://docs.projectcalico.org/manifests/calico.yaml
No idea why.
2) The pods of my first test deployment didn’t get IPs from the subnet that I set by the –pod-network-cidr option when running kubeadm init. This was because the playbook installs crio, and along with it the conf file /etc/cni/net.d/100-crio-bridge.conf . This conf sets the default subnet to ‘10.85.0.0/16’. This can’t be overridden by –pod-network-cidr option of kubeadm init, respectively the latter will just be ignored.
My quick fix after I had run the playbook was running (remove the groupname [k8snodes] from hosts file first):
for node in $(cat hosts); do ssh root@$node ‘sed -i “s/10.85.0.0/A.B.C.D/g” /etc/cni/net.d/100-crio-bridge.conf && systemctl restart crio’; done
And finally destroy the calico pods and let k8s re-create them.
Hi! Thank you for this write-up, I used it to install my very first K8s Cluster on my homelab.
But I had two problems along the way getting it to finally work:
1) The tigera-operator didn’t spin up the calico pods. I had to additionaly run
kubectl create -f https://docs.projectcalico.org/manifests/calico.yaml
No idea why.
2) The pods of my first test deployment didn’t get IPs from the subnet that I set by the –pod-network-cidr option when running kubeadm init. This was because the playbook installs crio, and along with it the conf file /etc/cni/net.d/100-crio-bridge.conf . This conf sets the default subnet to ‘10.85.0.0/16’. This can’t be overridden by –pod-network-cidr option of kubeadm init, respectively the latter will just be ignored.
My quick fix after I had run the playbook was running (remove the groupname [k8snodes] from hosts file first):
for node in $(cat hosts); do ssh root@$node ‘sed -i “s/10.85.0.0/A.B.C.D/g” /etc/cni/net.d/100-crio-bridge.conf && systemctl restart crio’; done
Thanks for pointing out the issue and helping with the patch on Ansible role PR.
During the execution of the command “/root/.local/bin/ansible-playbook -i hosts k8s-prep.yml” as outlined in the guide, I encountered an error message.
The specific error message I received is
PLAY RECAP *********************************************************************
k8s-master-01 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
k8s-master-02 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
k8s-master-03 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
k8s-worker-01 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
k8s-worker-02 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
k8s-worker-03 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
k8s-worker-04 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
I greatly appreciate your expertise and was wondering if you could spare a moment to provide guidance on resolving this issue. Your assistance would be immensely valuable to me, and I am eager to learn from your insights.
Thank you very much for your time and consideration. I look forward to your guidance and appreciate your support in advance.
Hello,
it fails with the error below:
TASK [kubernetes-bootstrap : Update system packages] *************************************************************************************************
fatal: [svldev-k8s-n02]: FAILED! => {“changed”: false, “msg”: “Failed to download metadata for repo ‘devel_kubic_libcontainers_stable’: Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried”, “rc”: 1, “results”: []}
fatal: [svldev-k8s-m01]: FAILED! => {“changed”: false, “msg”: “Failed to download metadata for repo ‘devel_kubic_libcontainers_stable’: Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried”, “rc”: 1, “results”: []}
fatal: [svldev-k8s-n01]: FAILED! => {“changed”: false, “msg”: “Failed to download metadata for repo ‘devel_kubic_libcontainers_stable’: Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried”, “rc”: 1, “results”: []}
fatal: [svldev-k8s-m02]: FAILED! => {“changed”: false, “msg”: “Failed to download metadata for repo ‘devel_kubic_libcontainers_stable’: Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried”, “rc”: 1, “results”: []}
Please check your DNS configs