Before I ever touched a managed Kubernetes service, I learned the platform by installing it from scratch with kubeadm on plain Ubuntu boxes, and I still think it’s the best way to actually understand what’s happening under the hood — every component you’d otherwise take for granted on EKS or GKE, you have to wire up yourself. In this guide I’ll walk through a complete kubeadm-based installation on Ubuntu 18.04: container runtime, control plane initialization, networking, and joining worker nodes.
A quick note up front: Ubuntu 18.04 reached end of standard support some time ago, so if this is going into production rather than a lab/learning environment, I’d strongly recommend doing this same process on a currently-supported Ubuntu LTS release instead. The steps below are still accurate for 18.04 specifically, which is common in older infrastructure or training environments.
Architecture Overview
A kubeadm-built cluster has at least one control plane node (running the API server, scheduler, controller manager, and etcd) and one or more worker nodes (running the kubelet and your actual workload Pods). We’ll set up one control plane node and join at least one worker.
Step 1: Prepare Every Node (Control Plane and Workers)
Run these steps identically on every machine that will be part of the cluster.
Disable swap — the kubelet requires this:
sudo swapoff -a
sudo sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab
Load required kernel modules and sysctl settings:
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.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sudo sysctl --system
Step 2: Install a Container Runtime (containerd)
Kubernetes no longer supports Docker directly as a runtime (since the dockershim removal), so we install containerd:
sudo apt-get update
sudo apt-get install -y containerd
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml
Edit /etc/containerd/config.toml and set SystemdCgroup = true under the runc options section — this alignment with systemd cgroups is required for kubelet compatibility:
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl restart containerd
sudo systemctl enable containerd
Step 3: Install kubeadm, kubelet, and kubectl
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gpg
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key | \
sudo 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/ /' | \
sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl
apt-mark hold prevents these packages from being upgraded by routine apt upgrade runs — Kubernetes upgrades should always be deliberate and version-by-version, never accidental.
Verify:
kubeadm version
kubelet --version
Step 4: Initialize the Control Plane
On the control plane node only:
sudo kubeadm init --pod-network-cidr=192.168.0.0/16
The --pod-network-cidr value here matches what Calico (our CNI choice below) expects by default — adjust if using a different CNI plugin with a different default range.
This takes a few minutes and ends with output including a kubeadm join command — save this, you’ll need it for worker nodes:
Your Kubernetes control-plane has initialized successfully!
To start using your cluster, you need to run the following as a 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
...
kubeadm join 10.0.0.10:6443 --token abcdef.0123456789abcdef \
--discovery-token-ca-cert-hash sha256:1234...
Run the suggested commands to configure kubectl access:
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
NAME STATUS ROLES AGE VERSION
control-plane NotReady control-plane 1m v1.30.0
NotReady at this stage is expected — no CNI network plugin is installed yet.
Step 5: Install a CNI Plugin (Calico)
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/calico.yaml
Wait for pods to come up:
kubectl get pods -n kube-system --watch
NAME READY STATUS RESTARTS AGE
calico-kube-controllers-6f6cf5977-8xqwt 1/1 Running 0 40s
calico-node-4mzp1 1/1 Running 0 40s
coredns-5d78c9869d-2plq9 1/1 Running 0 2m
Check the node again:
kubectl get nodes
NAME STATUS ROLES AGE VERSION
control-plane Ready control-plane 3m v1.30.0
Step 6: Join Worker Nodes
On each worker node, having already completed Steps 1–3 (runtime and kubeadm/kubelet/kubectl installation), run the kubeadm join command saved from Step 4:
sudo kubeadm join 10.0.0.10:6443 --token abcdef.0123456789abcdef \
--discovery-token-ca-cert-hash sha256:1234...
If the original token has expired (they last 24 hours by default), generate a new one from the control plane:
kubeadm token create --print-join-command
Back on the control plane node, confirm the worker joined:
kubectl get nodes
NAME STATUS ROLES AGE VERSION
control-plane Ready control-plane 10m v1.30.0
worker-1 Ready <none> 1m v1.30.0
Step 7: (Optional) Allow Scheduling on the Control Plane
By default, the control plane node has a taint preventing regular workloads from being scheduled there. For small lab clusters where you don’t have dedicated workers, remove it:
kubectl taint nodes --all node-role.kubernetes.io/control-plane-
Don’t do this on anything resembling production — the control plane should stay dedicated to control plane components for both stability and security reasons.
Step 8: Deploy a Test Workload
kubectl create deployment nginx --image=nginx --replicas=2
kubectl expose deployment nginx --port=80 --type=NodePort
kubectl get svc nginx
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
nginx NodePort 10.96.55.201 <none> 80:31456/TCP 10s
curl http://<any-node-ip>:31456
If this returns the nginx welcome page, your cluster is fully functional end to end.
Step 9: High Availability Considerations (Beyond a Single Control Plane)
A single control plane node is a single point of failure. For anything beyond learning/testing, kubeadm supports stacking multiple control plane nodes behind a load balancer:
sudo kubeadm init \
--control-plane-endpoint "LOAD_BALANCER_DNS:6443" \
--upload-certs \
--pod-network-cidr=192.168.0.0/16
Additional control plane nodes then join using a --control-plane flag alongside the join command, and etcd runs as a stacked cluster across them for redundancy — a topic worth a deeper dive of its own if you’re building this for real production use.
Debugging Installation Issues
sudo systemctl status kubelet
sudo journalctl -u kubelet -f
kubectl get pods -n kube-system
kubectl describe node <node-name>
A node stuck NotReady almost always traces back to either a missing/misconfigured CNI plugin or a containerd/kubelet cgroup driver mismatch — double check the SystemdCgroup = true setting from Step 2 if you hit this.
Security Best Practices
- Rotate the join token regularly and avoid leaving long-lived tokens in shared documentation.
- Restrict API server access (port 6443) at the network/firewall level to known IP ranges.
- Enable audit logging on the API server for production clusters.
- Keep
kubeadm,kubelet, andkubectlversions in lockstep with a defined upgrade cadence rather than letting them drift. - Apply Pod Security Standards and NetworkPolicies immediately after cluster creation — a fresh
kubeadmcluster has no such restrictions by default.
Common Mistakes
- Forgetting to disable swap, which causes the kubelet to fail to start entirely on newer versions.
- Mismatched cgroup drivers between containerd and kubelet, causing nodes to appear
Readybut Pods to fail scheduling in strange ways. - Using an outdated Docker-based tutorial after dockershim removal, then wondering why
kubeadm initfails to find a compatible runtime. - Not saving the
kubeadm joincommand output, then having to regenerate a token later.
Choosing an Alternative CNI Plugin
Calico is a solid, well-documented default, but it’s worth knowing the alternatives and why you might pick one over another on a self-managed cluster. Flannel is simpler and lighter-weight but doesn’t support NetworkPolicy enforcement on its own — fine for a lab cluster, not appropriate if you need network segmentation. Cilium uses eBPF instead of iptables for packet handling, which generally performs better at scale and adds rich Layer 7-aware policy capabilities beyond what standard NetworkPolicy offers (see the dedicated NetworkPolicy guide). If you’re building this cluster specifically to learn the platform deeply, I’d actually recommend trying Cilium instead of Calico at least once — installing it follows the same basic pattern:
helm repo add cilium https://helm.cilium.io/
helm install cilium cilium/cilium --version 1.16.0 \
--namespace kube-system \
--set kubeProxyReplacement=true \
--set k8sServiceHost=<control-plane-ip> \
--set k8sServicePort=6443
Note kubeProxyReplacement=true here — Cilium can replace kube-proxy entirely with its own eBPF-based service routing, which is a meaningfully different (and generally faster) approach than the iptables-based rules kube-proxy installs by default.
Verifying the Cluster Thoroughly
Beyond a single nginx test Pod, I run through a slightly more thorough validation before considering a fresh cluster genuinely ready:
# Core components healthy
kubectl get componentstatuses
kubectl get pods -n kube-system
# DNS resolution works
kubectl run dnstest --rm -it --image=busybox:1.36 -- nslookup kubernetes.default
# Cross-node Pod networking works
kubectl run pod-a --image=busybox:1.36 --command -- sleep 3600
kubectl run pod-b --image=busybox:1.36 --command -- sleep 3600
kubectl get pods -o wide # confirm they landed on different nodes
kubectl exec pod-a -- ping -c 3 <pod-b-ip>
# Storage provisioning works (if a CSI driver is installed)
kubectl apply -f test-pvc.yaml
kubectl get pvc test-pvc
Confirming Pod-to-Pod networking across different nodes specifically (not just on the same node) catches a class of CNI misconfiguration that a single-node test can miss entirely — routing between nodes is a genuinely different code path than routing within one.
Setting Up an On-Prem Load Balancer with MetalLB
Cloud-managed clusters get LoadBalancer Services for free via the cloud provider’s integration. A bare-metal kubeadm cluster has no such integration by default — type: LoadBalancer Services will sit in <pending> forever without something to fulfill them. MetalLB fills this gap for on-prem and bare-metal setups:
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.14.5/config/manifests/metallb-native.yaml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default-pool
namespace: metallb-system
spec:
addresses:
- 192.168.1.240-192.168.1.250
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: l2-advert
namespace: metallb-system
spec:
ipAddressPools:
- default-pool
With this in place, type: LoadBalancer Services get a real, reachable IP from the specified range on your local network, using ARP-based Layer 2 advertisement — a reasonable default for smaller on-prem setups, with BGP mode available for larger, router-integrated deployments.
Certificate Renewal and Cluster Maintenance
kubeadm-managed clusters use short-lived certificates (one year by default) for internal cluster communication, and letting these silently expire is a genuinely common cause of mysterious, hard-to-diagnose cluster outages on long-running self-managed clusters. Check certificate expiry proactively rather than reactively:
sudo kubeadm certs check-expiration
CERTIFICATE EXPIRES RESIDUAL TIME
admin.conf Jul 15, 2027 10:03 UTC 364d
apiserver Jul 15, 2027 10:03 UTC 364d
apiserver-etcd-client Jul 15, 2027 10:03 UTC 364d
Renew proactively, well before expiry, rather than waiting for an outage to force the issue:
sudo kubeadm certs renew all
sudo systemctl restart kubelet
I’d set a recurring calendar reminder (or better, a monitoring alert on cert expiry) for any self-managed cluster expected to run longer than a few months — this is exactly the kind of maintenance task managed services like EKS handle invisibly, and one of the clearest illustrations of what you’re actually taking on by choosing the self-managed path.
Summary
Installing Kubernetes on Ubuntu 18.04 with kubeadm means preparing the OS (disabling swap, kernel modules), installing containerd as the runtime, installing the kubeadm/kubelet/kubectl trio, initializing the control plane, installing a CNI plugin like Calico, and joining worker nodes with the generated token. It’s more manual than a managed service, but walking through it once by hand is one of the best ways to actually understand what a managed Kubernetes offering is doing for you behind the scenes.