What Kubernetes Actually Does and When You Need It

Kubernetes is a container orchestrator. It schedules containers across a pool of nodes, restarts failed workloads, manages service discovery, and handles rolling updates without downtime. You need it when you have more containers than one person can reasonably babysit, when you need horizontal scaling under load, or when you want declarative infrastructure that survives a node dying at 2am.

What Kubernetes is not: a replacement for proper configuration management, a silver bullet for stateful applications, or something you should run on a single 1GB VPS. The control plane alone needs at least 2 vCPUs and 2GB RAM on the master node. Worker nodes need headroom on top of that for actual workloads. We run our test cluster on three nodes: one control plane at 4 vCPUs / 8GB RAM, two workers at 2 vCPUs / 4GB RAM each.

The core components you will interact with daily: the API server (all kubectl commands hit this), etcd (the cluster state database, treat it like your most important database), the scheduler (places pods on nodes), the controller manager (reconciles desired state with actual state), and kubelet (the per-node agent that actually runs containers). On every worker, you also have kube-proxy handling iptables or IPVS rules for service networking.

# Minimum kernel version check before you start
uname -r
# You want 4.19+ for production; 5.15 LTS or 6.1 LTS preferred

# Check required kernel modules
lsmod | grep -E 'br_netfilter|overlay'

# Load them if missing
modprobe br_netfilter
modprobe overlay

Kernel and OS Preparation on Linux

Before touching kubeadm or any Kubernetes tooling, the OS needs specific configuration. Kubernetes requires IP forwarding, bridge traffic through iptables, and swap disabled. Skipping these causes cryptic failures during cluster init that waste hours.

Swap must be off. kubelet refuses to start with swap enabled by default. You can override this with the --fail-swap-on=false flag, but do not do this on production. Swap causes unpredictable memory behavior that breaks the container memory limits Kubernetes relies on.

The br_netfilter module makes iptables see bridged traffic, which kube-proxy needs. Without it, pod-to-pod networking breaks silently in ways that are painful to diagnose. Set these in a persistent sysctl file, not just at runtime.

SELinux on RHEL-family systems: you can run Kubernetes with SELinux enforcing, and you should. The container runtimes have supported it properly since 2022. Do not blindly set it to permissive mode just because a tutorial told you to.

# Disable swap immediately and permanently
swapoff -a
sed -i '/swap/d' /etc/fstab

# Persist kernel settings
cat < /etc/sysctl.d/99-kubernetes.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF

sysctl --system

# Persist module loading
cat < /etc/modules-load.d/kubernetes.conf
br_netfilter
overlay
EOF

# Firewall: if using firewalld on RHEL/Rocky
firewall-cmd --permanent --add-port=6443/tcp    # API server
firewall-cmd --permanent --add-port=10250/tcp   # kubelet
firewall-cmd --permanent --add-port=2379-2380/tcp # etcd
firewall-cmd --reload

Container Runtime: containerd Over Docker

Kubernetes dropped the Docker shim in version 1.24. The correct runtime in 2026 is containerd, which runs under the CRI (Container Runtime Interface) that Kubernetes speaks natively. CRI-O is a valid alternative, especially on OpenShift-adjacent setups, but containerd is the most widely tested and has the simplest operational profile.

Do not install Docker and assume containerd comes along for the ride in the right configuration. Install containerd directly from the Docker repository or your distro's packages, then generate the default config and make two specific changes: set the cgroup driver to systemd (Kubernetes 1.22+ requires this to match kubelet), and enable the CRI plugin, which is disabled in some default configs.

On Ubuntu 24.04, the containerd package from the official Docker repo is version 1.7.x. On Rocky Linux 9, use the docker-ce repository as well - the default AppStream containerd is older and sometimes missing the CRI plugin.

# Ubuntu 24.04
apt-get install -y containerd.io

# Generate default config
mkdir -p /etc/containerd
containerd config default > /etc/containerd/config.toml

# Set systemd cgroup driver
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml

# Verify the CRI plugin is NOT in the disabled_plugins list
grep 'disabled_plugins' /etc/containerd/config.toml
# Should return: disabled_plugins = []
# If cri is listed there, remove it

systemctl enable --now containerd
systemctl status containerd

# Confirm CRI socket is available
ls -la /run/containerd/containerd.sock
// advertisement

Installing kubeadm, kubelet, and kubectl

kubeadm is the official cluster bootstrapping tool. It handles certificate generation, etcd initialization, and control plane pod creation. It does not manage ongoing upgrades for you in the way a managed service would, but it makes initial setup deterministic.

Pin the package versions. Kubernetes repositories split by minor version since 1.28, so you add a versioned repository URL. In 2026 we are targeting 1.30.x for a stable production setup, or 1.31.x if you are comfortable with a slightly shorter patch window.

After installation, enable kubelet but do not try to start it manually. kubeadm init will start it as part of the init sequence. If kubelet is running without a valid cluster config it will crash-loop, which is expected and not a problem at this stage.

# Ubuntu 24.04 - add Kubernetes 1.30 repo
apt-get install -y apt-transport-https ca-certificates curl gpg

curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key | \
  gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \
  https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /' \
  > /etc/apt/sources.list.d/kubernetes.list

apt-get update
apt-get install -y kubelet=1.30.4-1.1 kubeadm=1.30.4-1.1 kubectl=1.30.4-1.1
apt-mark hold kubelet kubeadm kubectl

# Rocky Linux 9
cat < /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.30/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.30/rpm/repodata/repomd.xml.key
EOF

dnf install -y kubelet-1.30.4 kubeadm-1.30.4 kubectl-1.30.4
dnf versionlock add kubelet kubeadm kubectl
systemctl enable kubelet

Initializing the Control Plane with kubeadm

Before running kubeadm init, decide on your pod network CIDR. This cannot be changed after init without destroying the cluster. The two most common choices are 10.244.0.0/16 (Flannel default) and 192.168.0.0/16 (Calico default). We use Calico in our test environment for its network policy support, so we pass 192.168.0.0/16.

The --control-plane-endpoint flag matters if you ever want to add more control plane nodes or put a load balancer in front. Use a hostname or VIP here, not just the current IP. We have seen too many clusters where this was skipped and adding HA later required a full rebuild. If you are running on cloud infrastructure, this is the place to use your internal load balancer address. For a single control plane node on a VPS from Vultr (https://vultr.com/?ref=PLACEHOLDER), you can use the public IP or a private network IP depending on your security posture.

kubeadm init takes 2-4 minutes on a reasonably fast machine. It writes a kubeadm-init.out file in /var/log/ - save the output. The join command at the end contains a token and a CA hash that expires in 24 hours. Save it or generate a new one later with kubeadm token create --print-join-command.

After init, copy the admin kubeconfig to your user's home directory to use kubectl without sudo. Run kubectl get nodes immediately to confirm the control plane is registered, then check kubectl get pods -n kube-system to see the system pods. CoreDNS pods will show Pending until you install a CNI plugin - this is normal.

# Run on the control plane node
kubeadm init \
  --pod-network-cidr=192.168.0.0/16 \
  --control-plane-endpoint="k8s-cp.internal.example.com" \
  --upload-certs \
  --kubernetes-version=1.30.4 \
  2>&1 | tee /var/log/kubeadm-init.out

# Set up kubectl access for your user
mkdir -p $HOME/.kube
cp /etc/kubernetes/admin.conf $HOME/.kube/config
chown $(id -u):$(id -g) $HOME/.kube/config

# Verify control plane is up
kubectl get nodes
kubectl get pods -n kube-system

# Check component health
kubectl get componentstatuses

CNI Networking: Installing Calico

Without a CNI (Container Network Interface) plugin, pods cannot communicate and CoreDNS stays Pending. Calico is the right default choice for most sysadmins: it works with or without an overlay network, supports NetworkPolicy resources for pod-level firewalling, and has solid debugging tools.

Flannel is simpler but lacks NetworkPolicy support. Cilium is powerful and adds eBPF-based observability, but its operational complexity is not justified unless you need its specific features. Weave Net is effectively unmaintained at this point in 2026.

Calico version 3.28 installs cleanly with a single manifest apply. After applying it, watch the calico-node DaemonSet until all pods are Running. The control plane node will move from NotReady to Ready within 60-90 seconds of the calico-node pod starting.

If your nodes use multiple network interfaces, you may need to tell Calico which interface to use by setting the IP_AUTODETECTION_METHOD environment variable in the DaemonSet. The default works on single-interface nodes.

# Install Calico 3.28
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/calico.yaml

# Watch rollout
kubectl rollout status daemonset/calico-node -n kube-system

# Confirm node is Ready
kubectl get nodes
# NAME               STATUS   ROLES           AGE   VERSION
# k8s-cp             Ready    control-plane   5m    v1.30.4

# If multi-interface issues arise, patch the DaemonSet:
kubectl set env daemonset/calico-node -n kube-system \
  IP_AUTODETECTION_METHOD=interface=eth0
// advertisement

Joining Worker Nodes

Every worker node needs the same OS prep and containerd setup you did on the control plane. Automate this. If you are managing more than two nodes, write an Ansible role or at minimum a shell script. Doing it by hand on five nodes introduces configuration drift that will cost you debugging time later.

For teams building out a full automation pipeline, tools like those aggregated at taskbotshub.ai can help identify AI-assisted DevOps workflows worth integrating, including automated node provisioning and drift detection.

The join command from kubeadm init is a one-liner. Run it on each worker as root. It contacts the API server, downloads the cluster CA, and registers the kubelet. The process takes under 30 seconds on a decent network connection. After joining, confirm on the control plane with kubectl get nodes.

For cloud setups on DigitalOcean (https://digitalocean.com/?refcode=PLACEHOLDER), the private networking feature gives you a dedicated internal IP for inter-node communication - use that as the --apiserver-advertise-address equivalent so cluster traffic stays off the public interface.

# Run on each worker node (as root)
# Replace with your actual token, hash, and control plane endpoint
kubeadm join k8s-cp.internal.example.com:6443 \
  --token abcdef.0123456789abcdef \
  --discovery-token-ca-cert-hash sha256:abc123...

# If the 24h token expired, generate a new join command from the control plane:
kubeadm token create --print-join-command

# Verify from control plane
kubectl get nodes -o wide
# NAME       STATUS   ROLES           AGE   VERSION   INTERNAL-IP
# k8s-cp     Ready    control-plane   10m   v1.30.4   10.0.0.1
# worker-01  Ready              2m    v1.30.4   10.0.0.2
# worker-02  Ready              90s   v1.30.4   10.0.0.3

Deploying Your First Workload

Run something real immediately after standing up the cluster. A deployment with a rollout and a service will confirm scheduling, networking, and DNS all work end to end. We use nginx for this smoke test - it is lightweight and its default page confirms the service is reachable.

Kubernetes objects are defined in YAML manifests. A Deployment manages a ReplicaSet which manages Pods. A Service exposes those pods on a stable IP and DNS name within the cluster. ClusterIP services are internal only. NodePort exposes a port on every node's IP. LoadBalancer provisions an external load balancer (only works natively on cloud providers with a controller, or on-prem with MetalLB).

For this smoke test, NodePort is fine. After confirming the service responds, delete the test deployment. Do not leave random test resources running - they consume scheduler attention and confuse future you when you are looking at resource usage.

When you start naming real projects and services, consistent naming conventions matter from day one. If your project also needs a public domain, register it before you build cluster ingress rules around it - we have used nicename.me for quick domain lookups when spinning up new project namespaces.

# Create a test deployment
kubectl create deployment nginx-test --image=nginx:1.27 --replicas=2

# Expose it via NodePort
kubectl expose deployment nginx-test --port=80 --type=NodePort

# Check the assigned NodePort (will be in 30000-32767 range)
kubectl get service nginx-test
# NAME         TYPE       CLUSTER-IP      PORT(S)        AGE
# nginx-test   NodePort   10.96.45.12     80:31234/TCP   30s

# Test from any node (replace IP and port)
curl http://10.0.0.2:31234

# Verify DNS within the cluster
kubectl run dns-test --image=busybox:1.36 --rm -it --restart=Never -- \
  nslookup nginx-test.default.svc.cluster.local

# Clean up
kubectl delete deployment nginx-test
kubectl delete service nginx-test

Namespaces, RBAC, and Basic Security Posture

The default namespace is where tutorials dump everything and where real clusters collect chaos. Use namespaces from day one. Create one per team, application, or environment. Namespace isolation is logical, not a security boundary by itself - use NetworkPolicy and RBAC together to enforce real separation.

RBAC in Kubernetes uses Roles (namespace-scoped) and ClusterRoles (cluster-wide), bound to users or ServiceAccounts via RoleBindings and ClusterRoleBindings. The admin kubeconfig you are using right now has cluster-admin privileges. Do not use it for application deployments. Create a ServiceAccount with the minimum Role needed for each application.

Three immediate security settings worth enabling on any cluster: disable anonymous auth on the API server (it should be off by default with kubeadm), set PodSecurity admission to at least warn on restricted, and audit log to disk. kubeadm clusters have audit logging disabled by default. Enable it by editing the API server manifest at /etc/kubernetes/manifests/kube-apiserver.yaml.

The kubelet API is also exposed on port 10250 and can execute commands in containers. Ensure your firewall blocks access to this port from outside the cluster nodes. The kubeadm-generated kubelet config enables authentication, but firewall rules are a necessary second layer.

# Create a namespace and a restricted deployment user
kubectl create namespace production

# Create a ServiceAccount
kubectl create serviceaccount deploy-bot -n production

# Create a Role with deployment permissions only
cat <
// advertisement

Persistent Storage and ConfigMaps

Stateless workloads are straightforward in Kubernetes. Stateful workloads need persistent storage, which is where many sysadmins hit friction. Kubernetes uses PersistentVolumes (PVs) and PersistentVolumeClaims (PVCs) to abstract the underlying storage.

On bare metal or a basic VPS setup, the simplest option is local-path-provisioner from Rancher. It uses a directory on the node's disk and creates PVs dynamically. It does not support ReadWriteMany access mode and does not survive node failures, but it works immediately and is appropriate for single-node or dev clusters.

For a production cluster that needs shared storage, your options in 2026 are: NFS with the NFS subdir external provisioner, Rook/Ceph for block and file storage on bare metal, or cloud-specific storage classes (Vultr Block Storage has a CSI driver, as does DigitalOcean Volumes). We have run Rook/Ceph on three-node clusters and it works but requires dedicated disks and adds significant operational surface area.

ConfigMaps and Secrets are how you pass configuration to pods. ConfigMaps for non-sensitive data, Secrets for credentials. Secrets are base64-encoded but not encrypted at rest by default - enable etcd encryption for production. The manifest for that goes in /etc/kubernetes/manifests/ alongside the other static pods.

# Install local-path-provisioner (Rancher) for quick persistent storage
kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.28/deploy/local-path-storage.yaml

# Make it the default storage class
kubectl patch storageclass local-path \
  -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

# Test with a PVC
cat <

Cluster Maintenance: Upgrades and Node Draining

Kubernetes minor version upgrades happen every 4 months. Each minor version receives patch releases for about 14 months. If you are running 1.30.x in mid-2026, plan to upgrade to 1.31.x before end of year. The upgrade path is always one minor version at a time: 1.30 to 1.31, never 1.30 to 1.32 directly.

kubeadm handles upgrades cleanly. The sequence is: upgrade kubeadm, run kubeadm upgrade plan to see what will change, run kubeadm upgrade apply, then upgrade kubelet and kubectl on the control plane. For each worker, drain the node to move its pods elsewhere, upgrade the packages, and uncordon it.

Node draining with kubectl drain is the right way to take a node offline for maintenance. It evicts pods with a grace period (respecting PodDisruptionBudgets if you have set them) and marks the node unschedulable. If a pod has no ReplicaSet parent (a bare pod), kubectl drain will fail unless you pass --force. That is intentional - bare pods do not reschedule anywhere after eviction.

# Check available upgrade path
kubeadm upgrade plan

# Upgrade control plane (example: 1.30.4 -> 1.31.0)
apt-mark unhold kubeadm
apt-get install -y kubeadm=1.31.0-1.1
apt-mark hold kubeadm

kubeadm upgrade apply v1.31.0

# Upgrade kubelet and kubectl on control plane
apt-mark unhold kubelet kubectl
apt-get install -y kubelet=1.31.0-1.1 kubectl=1.31.0-1.1
apt-mark hold kubelet kubectl
systemctl daemon-reload && systemctl restart kubelet

# Drain a worker node before upgrading it
kubectl drain worker-01 --ignore-daemonsets --delete-emptydir-data

# SSH to worker-01, upgrade packages there
# (same apt commands as above but for the worker)

# Uncordon after upgrade
kubectl uncordon worker-01

# Verify
kubectl get nodes