How to Install Kubernetes on Ubuntu 18.04

How to Install Kubernetes on Ubuntu 18.04

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, and kubectl versions in lockstep with a defined upgrade cadence rather than letting them drift.
  • Apply Pod Security Standards and NetworkPolicies immediately after cluster creation — a fresh kubeadm cluster 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 Ready but Pods to fail scheduling in strange ways.
  • Using an outdated Docker-based tutorial after dockershim removal, then wondering why kubeadm init fails to find a compatible runtime.
  • Not saving the kubeadm join command 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.

References

Total
2
Shares

Leave a Reply

Previous Post
How to Create a Combo Chart in Excel

How to Create a Combo Chart in Excel

Next Post
How to Set Up a Kubernetes Cluster on AWS

How to Set Up a Kubernetes Cluster on AWS

Related Posts