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.

RoleHostnameIP Address
Control Planek8s-control01192.168.1.10
Worker Node 1k8s-worker01192.168.1.11
Worker Node 2k8s-worker02192.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.

6 COMMENTS

  1. 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.

  2. 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

  3. 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.

  4. 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”: []}

LEAVE A REPLY

Please enter your comment!
Please enter your name here