Kubernetes is the standard way to run containers across more than one server, and kubeadm is the upstream tool for building a conformant cluster yourself. This guide builds a three-node Kubernetes cluster on AlmaLinux 10 with containerd and Calico, then walks through practical examples: Deployments, Services, ConfigMaps and Secrets, scaling, rolling updates and rollbacks, Jobs and CronJobs, and resource limits.
Versions at the time of writing: AlmaLinux 10.2, Kubernetes 1.37 from the official pkgs.k8s.io repository, containerd 2.4 and Calico 3.33. The same steps work on RHEL 10, Rocky Linux 10 and Oracle Linux 10, and with small changes on the EL9 family. If you are new to containers, start with Docker Examples on AlmaLinux 10; for the bigger picture, see Kubernetes in 2026.
Lab layout
k8s-cp1.example.com192.168.1.51, control planek8s-w1.example.com192.168.1.52, workerk8s-w2.example.com192.168.1.53, worker- Pod network 10.244.0.0/16 and Service network 10.96.0.0/12 (kubeadm default), chosen so they do not overlap the 192.168.1.0/24 LAN
Prerequisites
- Three AlmaLinux 10 servers with at least 2 vCPUs and 2 GB RAM each (4 GB on the control plane is more comfortable) and 20 GB free disk
- Unique hostnames, MAC addresses and
/sys/class/dmi/id/product_uuidvalues on every node (watch out for cloned VMs) - Full network connectivity between the nodes and outbound internet access to
pkgs.k8s.io,registry.k8s.io,download.docker.com,quay.ioand GitHub - A user with
sudorights on every node
Step 1: Prepare every node
Run the commands in Steps 1 to 4 on all three nodes. Start with name resolution; skip the /etc/hosts entries if DNS already resolves the names.
sudo hostnamectl set-hostname k8s-cp1.example.com # k8s-w1 / k8s-w2 on the workers
cat <<EOF | sudo tee -a /etc/hosts
192.168.1.51 k8s-cp1.example.com k8s-cp1
192.168.1.52 k8s-w1.example.com k8s-w1
192.168.1.53 k8s-w2.example.com k8s-w2
EOF
The kubelet refuses to start with swap enabled by default, so turn swap off now and on every boot:
sudo swapoff -a
sudo sed -i '/\sswap\s/ s/^/#/' /etc/fstab
free -h # the Swap line should show 0B
Load the kernel modules and sysctls that container networking needs:
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
EOF
sudo sysctl --system
sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables
The upstream kubeadm instructions for Red Hat-based systems put SELinux in permissive mode, because the kubelet and several network plugins need host file access that the default policy blocks:
sudo setenforce 0
sudo sed -i 's/^SELINUX=enforcing$/SELINUX=permissive/' /etc/selinux/config
getenforce
Step 2: Open the firewall ports
Kubernetes and Calico need these ports between the nodes. On the control plane:
sudo firewall-cmd --permanent --add-port={6443,2379-2380,10250,10257,10259}/tcp
sudo firewall-cmd --permanent --add-port={179,5473}/tcp --add-port=4789/udp
sudo firewall-cmd --permanent --zone=trusted --add-source={10.244.0.0/16,10.96.0.0/12}
sudo firewall-cmd --reload
On each worker:
sudo firewall-cmd --permanent --add-port={10250,10256}/tcp --add-port=30000-32767/tcp
sudo firewall-cmd --permanent --add-port={179,5473}/tcp --add-port=4789/udp
sudo firewall-cmd --permanent --zone=trusted --add-source={10.244.0.0/16,10.96.0.0/12}
sudo firewall-cmd --reload
Port 6443 is the API server, 2379-2380 etcd, 10250 the kubelet, 10256 kube-proxy health checks and 30000-32767 the NodePort range. Calico uses 179/tcp for BGP, 4789/udp for VXLAN and 5473/tcp for Typha. Trusting the Pod and Service ranges stops firewalld from rejecting forwarded container traffic.
Step 3: Install and configure containerd
containerd is the container runtime the kubelet talks to. The containerd.io package from Docker’s repository is current and well maintained on EL10. Remove the conflicting Podman packages first, as in the Docker guide:
sudo dnf remove -y podman buildah runc
sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
sudo dnf -y install containerd.io
The packaged /etc/containerd/config.toml disables the CRI plugin that Kubernetes needs. Replace it with a full default configuration and switch runc to the systemd cgroup driver, which is what kubeadm configures the kubelet to use on cgroup v2 hosts:
sudo mv /etc/containerd/config.toml /etc/containerd/config.toml.orig
containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
grep -n 'SystemdCgroup' /etc/containerd/config.toml
sudo systemctl enable --now containerd
sudo systemctl restart containerd
The grep should show SystemdCgroup = true under the [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options] section.
Step 4: Install kubeadm, kubelet and kubectl
Add the community-owned Kubernetes repository. Each minor version has its own repository, and the exclude line keeps a routine dnf upgrade from upgrading Kubernetes behind your back:
cat <<EOF | sudo tee /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.37/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.37/rpm/repodata/repomd.xml.key
exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
EOF
sudo dnf install -y kubelet kubeadm kubectl \
--setopt=disable_excludes=kubernetes --setopt=install_weak_deps=False
sudo systemctl enable --now kubelet
kubeadm version -o short
The kubelet restarts every few seconds until kubeadm init or kubeadm join gives it a configuration. That is expected. Point crictl at containerd so you can debug containers on the node later:
cat <<EOF | sudo tee /etc/crictl.yaml
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
EOF
Step 5: Initialize the control plane
On k8s-cp1 only, pre-pull the images and create the cluster. Setting --control-plane-endpoint to a DNS name now makes it possible to add more control-plane nodes or a load balancer later.
sudo kubeadm config images pull
sudo kubeadm init \
--control-plane-endpoint=k8s-cp1.example.com:6443 \
--apiserver-advertise-address=192.168.1.51 \
--pod-network-cidr=10.244.0.0/16
When it finishes, kubeadm prints a kubeadm join command. Save it. Then set up kubectl for your regular user:
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
kubectl get nodes
The node shows NotReady until a Pod network is installed.
Step 6: Install the Calico Pod network
Calico provides Pod networking and NetworkPolicy. Install its CRDs and the Tigera operator (Kubernetes 1.36 and later use the v3_projectcalico_org.yaml CRDs):
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.33.0/manifests/v3_projectcalico_org.yaml
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.33.0/manifests/tigera-operator.yaml
Download the default custom resources and change the IP pool to match the --pod-network-cidr you passed to kubeadm:
curl -O https://raw.githubusercontent.com/projectcalico/calico/v3.33.0/manifests/custom-resources.yaml
sed -i 's#cidr: 192.168.0.0/16#cidr: 10.244.0.0/16#' custom-resources.yaml
grep cidr custom-resources.yaml
kubectl create -f custom-resources.yaml
watch kubectl get tigerastatus
Wait until every component reports AVAILABLE True, then press Ctrl+C. kubectl get nodes should now show the control plane as Ready.
Step 7: Join the worker nodes
On k8s-w1 and k8s-w2, run the join command printed by kubeadm init. It looks like this:
sudo kubeadm join k8s-cp1.example.com:6443 --token abcdef.0123456789abcdef \
--discovery-token-ca-cert-hash sha256:<hash>
Tokens expire after 24 hours. To print a fresh join command later, run this on the control plane:
sudo kubeadm token create --print-join-command
Step 8: Verify the cluster
kubectl get nodes -o wide
kubectl get pods -A
kubectl cluster-info
kubectl label node k8s-w1.example.com k8s-w2.example.com node-role.kubernetes.io/worker=
All three nodes should be Ready, and every Pod in kube-system, calico-system and tigera-operator should be Running. The last command only adds a friendly role label to the workers. Enable shell completion while you are here:
echo 'source <(kubectl completion bash)' >> ~/.bashrc
source ~/.bashrc
Example 1: A namespace, a Deployment and a Service
Keep the examples in their own namespace so cleanup is one command. Save this as web.yaml:
apiVersion: v1
kind: Namespace
metadata:
name: demo
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: demo
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:1.28
ports:
- containerPort: 80
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
memory: 128Mi
readinessProbe:
httpGet:
path: /
port: 80
---
apiVersion: v1
kind: Service
metadata:
name: web
namespace: demo
spec:
type: NodePort
selector:
app: web
ports:
- port: 80
targetPort: 80
nodePort: 30080
kubectl apply -f web.yaml
kubectl -n demo get deploy,rs,pods,svc -o wide
curl -I http://192.168.1.52:30080
The Deployment keeps two nginx Pods running and spreads them across the workers. The NodePort Service makes them reachable on port 30080 of every node, and the readiness probe keeps a Pod out of the Service until nginx answers.
Example 2: Scale, roll out an update and roll back
kubectl -n demo scale deployment web --replicas=4
kubectl -n demo get pods -l app=web
kubectl -n demo set image deployment/web nginx=nginx:1.29
kubectl -n demo rollout status deployment/web
kubectl -n demo rollout history deployment/web
kubectl -n demo rollout undo deployment/web
kubectl -n demo get deploy web -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
A rolling update replaces Pods a few at a time, so the Service keeps answering throughout. rollout undo returns to the previous ReplicaSet. In production, change the image in the YAML file and re-apply it so Git stays the source of truth.
Example 3: ConfigMaps and Secrets
Keep configuration and credentials out of the image. Create a ConfigMap holding a custom page and a Secret holding a password:
kubectl -n demo create configmap web-content \
--from-literal=index.html='<h1>Served by Kubernetes on AlmaLinux 10</h1>'
kubectl -n demo create secret generic db-cred \
--from-literal=username=app --from-literal=password="$(openssl rand -base64 18)"
Mount the ConfigMap as files and expose the Secret as environment variables by patching the Deployment:
kubectl -n demo patch deployment web --type=strategic -p '
spec:
template:
spec:
volumes:
- name: content
configMap:
name: web-content
containers:
- name: nginx
volumeMounts:
- name: content
mountPath: /usr/share/nginx/html
envFrom:
- secretRef:
name: db-cred
'
kubectl -n demo rollout status deployment/web
curl http://192.168.1.52:30080
kubectl -n demo exec deploy/web -- printenv username
Secrets are only base64-encoded in etcd by default. Limit who can read them with RBAC, and consider enabling encryption at rest for Secrets on the API server for production clusters.
Example 4: Jobs and CronJobs
A Job runs Pods to completion; a CronJob creates Jobs on a schedule.
kubectl -n demo create job pi --image=perl:5.40 -- perl -Mbignum=bpi -wle 'print bpi(500)'
kubectl -n demo wait --for=condition=complete job/pi --timeout=120s
kubectl -n demo logs job/pi
kubectl -n demo create cronjob hello --image=busybox:1.37 \
--schedule='*/5 * * * *' -- /bin/sh -c 'date; echo Hello from the cluster'
kubectl -n demo get cronjob hello
kubectl -n demo create job hello-now --from=cronjob/hello
kubectl -n demo logs job/hello-now
Example 5: Resource quotas and debugging
A ResourceQuota stops one namespace from consuming the whole cluster:
kubectl -n demo create quota demo-quota \
--hard=requests.cpu=2,requests.memory=2Gi,limits.memory=4Gi,pods=20
kubectl -n demo describe quota demo-quota
The commands you will use most when something misbehaves:
kubectl -n demo describe pod <pod-name> # events, probe failures, scheduling
kubectl -n demo logs <pod-name> --previous # logs from the last crashed container
kubectl -n demo get events --sort-by=.lastTimestamp
kubectl -n demo exec -it deploy/web -- sh
kubectl run tmp --rm -it --image=busybox:1.37 --restart=Never -- \
wget -qO- http://web.demo.svc.cluster.local
The last command starts a throwaway Pod and fetches the Service by its cluster DNS name, which proves CoreDNS and Pod-to-Service networking work. When you are finished, remove everything with kubectl delete namespace demo.
Troubleshooting
- kubeadm preflight fails on swap. Run
swapon --show. If anything is listed, repeat the swap commands in Step 1 and make sure no zram or swap unit is re-enabling it at boot (systemctl list-units --type swap). container runtime is not runningor CRI errors during init. The CRI plugin is still disabled. Check that/etc/containerd/config.tomlwas regenerated (it should not containdisabled_plugins = ["cri"]), restart containerd and runsudo crictl info | head.- Control-plane Pods keep restarting. This is the classic cgroup driver mismatch. Confirm
SystemdCgroup = truein the containerd config andcgroupDriver: systemdin/var/lib/kubelet/config.yaml, then checksudo journalctl -u kubelet -b --no-pager | tail -50. - Nodes stay
NotReady. The Pod network is not up. Runkubectl get tigerastatusandkubectl -n calico-system get pods, and make sure thecidrincustom-resources.yamlmatches--pod-network-cidr. - Pods on different nodes cannot reach each other, or DNS times out. Usually the firewall. Re-check the Calico ports and trusted sources from Step 2 with
sudo firewall-cmd --list-all --zone=publicand--zone=trusted. Temporarily stopping firewalld on a lab cluster quickly confirms whether it is the cause. kubeadm joinfails with an expired token or a certificate hash error. Generate a new command withkubeadm token create --print-join-command, and check that the worker can resolvek8s-cp1.example.comand reach port 6443.- Pods stuck in
ImagePullBackOff. Runkubectl describe podto see the exact registry error, then test from the node withsudo crictl pull <image>. Typos in tags and Docker Hub rate limits are the usual causes. - Starting over. On a node you want to rebuild, run
sudo kubeadm reset -f, then remove/etc/cni/net.dand$HOME/.kube/configbefore running init or join again.
Next steps
From here, add persistent storage with a CSI driver. If you built the cluster from Deploy a Ceph Storage Cluster on AlmaLinux 10 with cephadm, the Ceph CSI driver gives you RBD block volumes and CephFS shared volumes. An Ingress controller, metrics-server and a GitOps workflow are the other usual additions. The official kubeadm cluster guide, container runtime notes and Calico on-premises install guide cover every option used here.
