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, readingsystemctl statusoutput.
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:
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:
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:
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:
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:
If you missed the join command
If you lose it, generate a new one on the master with:
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:
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:
Back on the master, as the kube user:
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:
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:
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
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:
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:
- Distro Domain Discord — drop the error, screenshot, or
kubectl describe ...output. Real humans, no bots. - Comments on the YouTube video — I read every one.
Common failure modes:
- Swap not actually disabled —
free -hshould showSwap: 0 0 0 - SELinux still enforcing —
getenforceshould sayDisabled SystemdCgroupleft asfalsein/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