Skip to content

How to install Kubernetes on Rocky Linux

A complete walkthrough for building a real, production-shaped Kubernetes cluster on Rocky Linux 9 — one master, two workers, containerd as the runtime, Calico as the network. By the end you'll have a working kubectl get nodes that returns three Ready nodes, and an Nginx pod deployed across both workers to prove the network plane works.

What you'll build

                  ┌────────────────────────────┐
                  │   master (192.168.0.163)   │
                  │   control plane + etcd     │
                  └────────────┬───────────────┘
                               │  Calico overlay
                ┌──────────────┴──────────────┐
                ▼                             ▼
  ┌──────────────────────┐      ┌──────────────────────┐
  │  worker01 (.164)     │      │  worker02 (.167)     │
  │  kubelet + container │      │  kubelet + container │
  │  workloads           │      │  workloads           │
  └──────────────────────┘      └──────────────────────┘

Three Rocky Linux 9 VMs (or bare metal hosts). Each one gets containerd plus the Kubernetes tools. The master initializes the cluster and hands out join tokens; the workers join and run the actual pods.

Prerequisites

  • Three Rocky Linux 9.x machines, each with at least 4 CPU cores, 4GB RAM, and 50GB disk. VMs are fine — that's how the video does it.
  • All three nodes on the same L2 network, able to reach each other on every port we open.
  • Root access (or sudo) on all three.
  • Comfortable with the basics: SSH, editing files with vim, reading systemctl status output.

About the IPs

Throughout this guide the master is 192.168.0.163, worker01 is 192.168.0.164, worker02 is 192.168.0.167. Substitute your own. The hostnames I use are master, worker01, worker02 — keep them consistent because kubeadm ties the cluster's control-plane endpoint to the hostname.


Phase 1 — Prep every node

Do all of this on all three nodes before anything cluster-specific.

Install vim, disable swap, disable SELinux

Kubernetes will refuse to start kubelet if swap is enabled — it wants full control of memory pressure. SELinux gets disabled here because the upstream Kubernetes packages assume it's off; you can run with it enforcing once you understand the labels, but for a learning cluster it's one less variable.

# Install vim if it isn't already
dnf install -y vim

# Disable swap permanently — comment out the swap line in /etc/fstab
vim /etc/fstab
# (find the line ending in "swap swap defaults 0 0" and prefix it with #)

# Disable SELinux — change "enforcing" to "disabled"
vim /etc/selinux/config
# (set SELINUX=disabled)

SELinux in production

SELINUX=disabled is fine for a homelab learning cluster. For anything customer-facing, learn the labels and run SELINUX=permissive first, then enforcing — disabling outright leaves a meaningful chunk of defense-in-depth on the table.

Set the hostname

On each node, set the hostname so it matches the cluster topology:

# On the master
hostnamectl set-hostname master

# On worker01
hostnamectl set-hostname worker01

# On worker02
hostnamectl set-hostname worker02

Configure /etc/hosts on every node

Every node needs to resolve every other node by name. Add this block to /etc/hosts on all three:

192.168.0.163  master
192.168.0.164  worker01
192.168.0.167  worker02

Reboot

reboot

After the reboot, free -h should show Swap: 0 0 0 and getenforce should return Disabled. If either isn't right, fix it before going on — Kubernetes will refuse to come up.


Phase 2 — Common Kubernetes setup (every node)

Still all three nodes.

Load the kernel modules Kubernetes needs

Kubernetes needs overlay (for container filesystem layering) and br_netfilter (so the host firewall sees bridged traffic). Configure them to load at boot:

cat <<EOF > /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

# Load them now without rebooting
modprobe overlay
modprobe br_netfilter

Set the kernel networking parameters

Kubernetes needs bridge traffic to traverse iptables and IPv4 forwarding to be enabled:

cat <<EOF > /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

sysctl --system

Install the containerd runtime

containerd is what actually runs the containers under Kubernetes — dockerd is a thicker shell around it that we don't need. Pull it from the Docker CE repo:

dnf install -y yum-utils
yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
dnf install -y containerd.io

Configure containerd for Kubernetes

Two things matter here: regenerate the default config (the one shipped is minimal), and flip SystemdCgroup to true so containerd and the kubelet agree on which cgroup driver to use. Mismatched cgroup drivers is the #1 cause of "the cluster comes up but kubelet keeps restarting" issues.

# Back up the shipped config
mv /etc/containerd/config.toml /etc/containerd/config.toml.bkp

# Generate a fresh default
containerd config default > /etc/containerd/config.toml

# Edit it and change SystemdCgroup = false to SystemdCgroup = true
vim /etc/containerd/config.toml
# (search for "SystemdCgroup" — there's only one match)

systemctl enable --now containerd

Verify:

systemctl status containerd

Should be active (running).

Add the Kubernetes repo

The guide installs Kubernetes 1.28. If you're following along later, bump the version in both URLs to whatever stable version you want — the rest of the procedure doesn't change.

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

The exclude= line prevents accidental upgrades when you dnf update — Kubernetes upgrades need to happen deliberately, not as a side effect of patching the OS.

Install kubelet, kubeadm, kubectl

The --disableexcludes=kubernetes flag bypasses the exclude= we just set, but only for this install command:

dnf install -y kubelet kubeadm kubectl --disableexcludes=kubernetes
systemctl enable kubelet

kubelet will fail to start until the cluster is initialized — that's expected. Don't troubleshoot it yet.


Phase 3 — Firewall (different per role)

Master and worker nodes need different ports open. This is where the procedure splits.

On the master node

firewall-cmd --permanent --add-port={6443,2379,2380,10250,10251,10252,10257,10259,179}/tcp
firewall-cmd --permanent --add-port=4789/udp
firewall-cmd --reload
Port Component
6443 Kubernetes API server
2379–2380 etcd
10250 kubelet API
10251, 10252, 10257, 10259 kube-scheduler, kube-controller-manager
179 Calico BGP
4789/udp VXLAN overlay

On both worker nodes

firewall-cmd --permanent --add-port={179,10250,30000-32767}/tcp
firewall-cmd --permanent --add-port=4789/udp
firewall-cmd --reload
Port Component
10250 kubelet API
179 Calico BGP
30000–32767 NodePort service range
4789/udp VXLAN overlay

Phase 4 — Initialize the cluster (master only)

On the master, run kubeadm init with the control-plane endpoint matched to the hostname:

kubeadm init --control-plane-endpoint=master

This takes a minute or two. When it finishes, it prints two join commands and instructions for setting up kubectl. Save the output. Specifically, you need the worker join command — it'll look like:

kubeadm join master:6443 --token <token> \
    --discovery-token-ca-cert-hash sha256:<hash>

If you missed the join command

If you lose it, generate a new one on the master with:

kubeadm token create --print-join-command


Phase 5 — Set up a non-root user for kubectl

Don't run kubectl as root. Create a dedicated user with sudo rights and the cluster's admin kubeconfig:

# On the master, as root
useradd -G wheel kube
passwd kube

# Switch to the kube user (SSH back in as kube, or `su - kube`)
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

Verify:

kubectl get nodes

You should see one node: master, status NotReady. The NotReady is expected — there's no CNI yet, so the network plane isn't up. We'll fix that in a minute.


Phase 6 — Join the workers

On each worker, run the join command you saved from Phase 4:

kubeadm join master:6443 --token <your-token> \
    --discovery-token-ca-cert-hash sha256:<your-hash>

Back on the master, as the kube user:

kubectl get nodes

You should now see three nodes — all still NotReady until the CNI lands.


Phase 7 — Install Calico (the network)

Calico runs as a set of pods that take over the cluster's networking plane. One kubectl apply:

kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.26.1/manifests/calico.yaml

Watch it come up:

kubectl get pods --all-namespaces -w

Wait until every Calico pod plus coredns reads 1/1 Running. Takes 1–3 minutes depending on how fast your VMs pull the images. Press Ctrl-C to exit the watch.

Then check the nodes:

kubectl get nodes

All three should now be Ready.


Phase 8 — Label the worker nodes

Out of the box, workers show <none> in the ROLES column. Cosmetic, but cleaner if you label them:

kubectl label node worker01 node-role.kubernetes.io/worker=worker
kubectl label node worker02 node-role.kubernetes.io/worker=worker
kubectl get nodes
NAME       STATUS   ROLES           AGE   VERSION
master     Ready    control-plane   12m   v1.28.x
worker01   Ready    worker          7m    v1.28.x
worker02   Ready    worker          7m    v1.28.x

Phase 9 — Smoke test: deploy Nginx

If the cluster works, deploying an Nginx pod across both workers should just work:

kubectl create deployment webapp001 --image=nginx --replicas=2
kubectl expose deployment webapp001 --port=80
kubectl get deployments,pods,services -o wide

You should see two pods, one on each worker, both Running. Grab the cluster IP of the service from the services output, then curl from the master:

curl http://<service-cluster-ip>

You'll get the default Nginx welcome HTML back. That's the cluster proving it can pull an image, schedule pods, route traffic across the overlay, and load-balance through a Service — every piece of the data plane working at once.


What's next

You now have a working three-node Kubernetes cluster — but there are two big pieces still missing for anything resembling a production setup:

  • Ingress — what gets the cluster's services reachable from outside via DNS instead of <node-ip>:<NodePort>. Guide coming.
  • Persistent storage — pods that need to survive a restart need a storage layer; see the Storage category for upcoming guides on Longhorn, OpenEBS, and Rook-Ceph.

Got stuck?

Two fastest places to get help:

Common failure modes:

  • Swap not actually disabledfree -h should show Swap: 0 0 0
  • SELinux still enforcinggetenforce should say Disabled
  • SystemdCgroup left as false in /etc/containerd/config.toml — the #1 cause of "kubelet keeps restarting" after init
  • Firewall ports missing — re-run the firewall block, verify with firewall-cmd --list-all
  • Wrong/expired join token — regenerate on the master with kubeadm token create --print-join-command