Kubernetes has become the industry standard for container orchestration, powering everything from small startups to the largest cloud-native platforms on the planet. If you are managing containers with Docker or Docker Compose and have reached the point where you need automated scaling, self-healing, rolling updates, and multi-node deployments, Kubernetes is the next step. This guide takes you from zero to a working Kubernetes cluster using kubeadm on Ubuntu Server, covering the architecture, installation, first deployment, service exposure, and day-to-day management commands.

Prerequisites

Before you begin, make sure you have:

  • Two or more machines running Ubuntu Server 22.04 or 24.04 (physical or virtual)
  • Each machine with at least 2 GB of RAM and 2 CPUs
  • Full network connectivity between all machines (private network recommended)
  • Unique hostname, MAC address, and product_uuid on every node
  • Terminal access with sudo privileges on all nodes
  • Swap disabled on all nodes

What Is Kubernetes?

Kubernetes (often abbreviated as k8s) is an open-source container orchestration platform originally designed by Google and now maintained by the Cloud Native Computing Foundation (CNCF). It automates the deployment, scaling, and management of containerized applications.

At its core, Kubernetes solves a fundamental problem: when you have dozens or hundreds of containers running across multiple servers, you need a system to decide where each container runs, restart containers that fail, scale them up or down based on demand, and manage networking between them. Kubernetes does all of this declaratively — you describe the desired state of your application, and Kubernetes continuously works to make the actual state match.

Key concepts:

  • Pod — the smallest deployable unit, wrapping one or more containers
  • Service — a stable network endpoint that routes traffic to a set of Pods
  • Deployment — a declarative way to manage Pods with replicas and rolling updates
  • Namespace — a logical partition to isolate resources within a cluster
  • Node — a physical or virtual machine in the cluster

Kubernetes Architecture

A Kubernetes cluster consists of two types of nodes:

Control Plane (Master Node)

The control plane manages the overall cluster state and makes scheduling decisions. Its components include:

  • kube-apiserver — the front door to the cluster; all communication goes through the API server
  • etcd — a distributed key-value store that holds all cluster configuration and state
  • kube-scheduler — assigns Pods to nodes based on resource requirements and constraints
  • kube-controller-manager — runs controllers that handle routine tasks like maintaining the correct number of Pod replicas
  • cloud-controller-manager — integrates with cloud provider APIs (optional, for cloud deployments)

Worker Nodes

Worker nodes run your application containers. Each worker runs:

  • kubelet — the agent that communicates with the control plane and manages Pods on the node
  • kube-proxy — handles networking rules so Pods can communicate with each other and with external traffic
  • Container runtime — the software that actually runs containers (containerd is the standard)
┌─────────────────────────────────────────┐
│           Control Plane Node            │
│  ┌───────────┐  ┌──────────────────┐    │
│  │ API Server│  │ Controller Mgr   │    │
│  └───────────┘  └──────────────────┘    │
│  ┌───────────┐  ┌──────────────────┐    │
│  │   etcd    │  │    Scheduler     │    │
│  └───────────┘  └──────────────────┘    │
└─────────────────────────────────────────┘
         │                    │
    ┌────┴────┐          ┌───┴─────┐
    │ Worker 1│          │ Worker 2│
    │ kubelet │          │ kubelet │
    │ kube-   │          │ kube-   │
    │ proxy   │          │ proxy   │
    │ runtime │          │ runtime │
    └─────────┘          └─────────┘

Preparing All Nodes

The following steps must be performed on every node in the cluster (control plane and workers alike).

Disable Swap

Kubernetes requires swap to be disabled. The kubelet will not start if swap is active:

# Disable swap immediately
sudo swapoff -a

# Remove swap entries from fstab to persist across reboots
sudo sed -i '/ swap / s/^/#/' /etc/fstab

# Verify swap is off
free -h

Load Required Kernel Modules

cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

sudo modprobe overlay
sudo modprobe br_netfilter

Configure sysctl Parameters

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

# Apply without reboot
sudo sysctl --system

Verify the settings are applied:

sysctl net.bridge.bridge-nf-call-iptables net.bridge.bridge-nf-call-ip6tables net.ipv4.ip_forward

All three values should return 1.

Installing the Container Runtime (containerd)

Kubernetes needs a container runtime that implements the Container Runtime Interface (CRI). containerd is the industry standard and the recommended choice:

# Install containerd
sudo apt update
sudo apt install -y containerd

# Generate the default configuration
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml

# Enable SystemdCgroup (required for kubeadm)
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml

# Restart and enable containerd
sudo systemctl restart containerd
sudo systemctl enable containerd

Verify containerd is running:

sudo systemctl status containerd

The output should show active (running).

Installing kubeadm, kubelet, and kubectl

These three tools are the foundation of a kubeadm-based cluster:

  • kubeadm — bootstraps the cluster
  • kubelet — runs on every node and manages Pods
  • kubectl — the command-line tool for interacting with the cluster
# Install required packages
sudo apt update
sudo apt install -y apt-transport-https ca-certificates curl gpg

# Add the Kubernetes APT repository signing key
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.31/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

# Add the Kubernetes APT repository
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.31/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list

# Install the packages
sudo apt update
sudo apt install -y kubelet kubeadm kubectl

# Prevent automatic upgrades (critical for cluster stability)
sudo apt-mark hold kubelet kubeadm kubectl

Verify the installation:

kubeadm version
kubectl version --client

Initializing the Control Plane

Run the following command only on the control plane node:

sudo kubeadm init --pod-network-cidr=10.244.0.0/16

The --pod-network-cidr flag specifies the IP range for Pods. The value 10.244.0.0/16 is the default for Flannel. If you plan to use Calico, use 192.168.0.0/16 instead.

After initialization completes, you will see output similar to:

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

Then you can join any number of worker nodes by running the following on each as root:

  kubeadm join 192.168.1.100:6443 --token abcdef.0123456789abcdef \
      --discovery-token-ca-cert-hash sha256:abc123...

Save the kubeadm join command — you will need it to add worker nodes.

Configure kubectl for your user:

mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

Verify the control plane is running:

kubectl get nodes

The output will show the control plane node with a NotReady status — this is expected until you install a Pod network add-on.

Installing a Pod Network Add-on

Pods need a network plugin (CNI — Container Network Interface) to communicate with each other across nodes. Without it, the cluster is not functional.

Option A: Install Flannel

kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml

Option B: Install Calico

kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.27.0/manifests/calico.yaml

Wait for all system Pods to become ready:

kubectl get pods -n kube-system

After a minute or two, all Pods should show Running status. Now check the node status again:

kubectl get nodes

The control plane node should now show Ready.

Joining Worker Nodes

On each worker node, run the kubeadm join command you saved from the control plane initialization:

sudo kubeadm join 192.168.1.100:6443 --token abcdef.0123456789abcdef \
    --discovery-token-ca-cert-hash sha256:abc123...

If you lost the join command, you can regenerate it on the control plane:

kubeadm token create --print-join-command

After joining, verify all nodes from the control plane:

kubectl get nodes

Expected output:

NAME           STATUS   ROLES           AGE   VERSION
control-plane  Ready    control-plane   10m   v1.31.0
worker-01      Ready    <none>          2m    v1.31.0
worker-02      Ready    <none>          1m    v1.31.0

To label your worker nodes for better identification:

kubectl label node worker-01 node-role.kubernetes.io/worker=worker
kubectl label node worker-02 node-role.kubernetes.io/worker=worker

Deploying Your First Application

Now that the cluster is running, let us deploy a simple nginx web server.

Create a Deployment

kubectl create deployment nginx --image=nginx:latest --replicas=3

This tells Kubernetes: “I want 3 instances of nginx running at all times.” Kubernetes will schedule Pods across your worker nodes.

Check the deployment:

kubectl get deployments
NAME    READY   UP-TO-DATE   AVAILABLE   AGE
nginx   3/3     3            3           30s

View the individual Pods:

kubectl get pods -o wide

The -o wide flag shows which node each Pod is running on.

Using a YAML Manifest

For production workloads, you should define resources in YAML files. Create a file called nginx-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.27
        ports:
        - containerPort: 80
        resources:
          requests:
            memory: "64Mi"
            cpu: "250m"
          limits:
            memory: "128Mi"
            cpu: "500m"

Apply it:

kubectl apply -f nginx-deployment.yaml

Exposing Services

Pods are ephemeral and get new IP addresses when they restart. Services provide a stable endpoint to reach your application.

ClusterIP (Internal Only)

The default Service type. Accessible only from within the cluster:

kubectl expose deployment nginx-deployment --port=80 --target-port=80 --type=ClusterIP
kubectl get services
NAME               TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
nginx-deployment   ClusterIP   10.96.120.45    <none>        80/TCP    10s

NodePort (External Access via Node IP)

Exposes the Service on a static port on each node’s IP:

kubectl expose deployment nginx-deployment --port=80 --target-port=80 --type=NodePort --name=nginx-nodeport
kubectl get services nginx-nodeport
NAME             TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
nginx-nodeport   NodePort   10.96.55.10    <none>        80:31234/TCP   5s

You can now access the application at http://<any-node-ip>:31234.

LoadBalancer (Cloud Environments)

In cloud environments (AWS, GCP, Azure), the LoadBalancer type provisions an external load balancer automatically:

kubectl expose deployment nginx-deployment --port=80 --target-port=80 --type=LoadBalancer --name=nginx-lb

For bare-metal clusters, you can use MetalLB to provide LoadBalancer functionality.

Service YAML Manifest

apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  selector:
    app: nginx
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80
    nodePort: 30080
  type: NodePort

Scaling and Rolling Updates

Scaling Replicas

# Scale up to 5 replicas
kubectl scale deployment nginx-deployment --replicas=5

# Scale down to 2 replicas
kubectl scale deployment nginx-deployment --replicas=2

# Verify
kubectl get deployment nginx-deployment

Rolling Updates

Update the container image with zero downtime:

# Update the image
kubectl set image deployment/nginx-deployment nginx=nginx:1.27-alpine

# Watch the rollout progress
kubectl rollout status deployment/nginx-deployment

Kubernetes will gradually replace old Pods with new ones, ensuring the application remains available throughout the update.

Rollback

If something goes wrong:

# View rollout history
kubectl rollout history deployment/nginx-deployment

# Rollback to the previous version
kubectl rollout undo deployment/nginx-deployment

# Rollback to a specific revision
kubectl rollout undo deployment/nginx-deployment --to-revision=2

Inspecting and Debugging Workloads

Viewing Pod Logs

# Logs from a specific Pod
kubectl logs nginx-deployment-abc123

# Follow logs in real time
kubectl logs -f nginx-deployment-abc123

# Logs from all Pods in a Deployment
kubectl logs -l app=nginx

Executing Commands in a Pod

# Open a shell inside a running Pod
kubectl exec -it nginx-deployment-abc123 -- /bin/bash

# Run a single command
kubectl exec nginx-deployment-abc123 -- cat /etc/nginx/nginx.conf

Describing Resources

# Detailed information about a Pod
kubectl describe pod nginx-deployment-abc123

# Detailed information about a node
kubectl describe node worker-01

Troubleshooting

Pods Stuck in Pending State

This typically means the scheduler cannot find a node with sufficient resources:

kubectl describe pod <pod-name>

Look at the Events section at the bottom. Common causes:

  1. Insufficient CPU or memory on worker nodes
  2. Node taints preventing scheduling
  3. Pod affinity rules that cannot be satisfied

Pods in CrashLoopBackOff

The container starts but immediately exits:

# Check the logs
kubectl logs <pod-name>
kubectl logs <pod-name> --previous

# Check events
kubectl describe pod <pod-name>

Common causes:

  1. Application error or misconfiguration
  2. Missing environment variables or config maps
  3. Failing readiness or liveness probes

Nodes Show NotReady

# Check node conditions
kubectl describe node <node-name>

# On the affected node, check kubelet logs
sudo journalctl -u kubelet -f

Common causes:

  1. CNI plugin not installed or crashing
  2. kubelet cannot reach the API server
  3. Disk pressure, memory pressure, or PID pressure

Cannot Reach Services

# Verify Service endpoints
kubectl get endpoints <service-name>

# Test connectivity from within the cluster
kubectl run debug --image=busybox --rm -it -- wget -qO- http://<service-name>

Regenerating Join Tokens

If the join token has expired (tokens are valid for 24 hours by default):

kubeadm token create --print-join-command

kubectl Commands Reference

CommandDescription
kubectl get nodesList all nodes in the cluster
kubectl get podsList Pods in the current namespace
kubectl get pods -AList Pods in all namespaces
kubectl get servicesList all Services
kubectl get deploymentsList all Deployments
kubectl describe pod <name>Show detailed Pod information
kubectl logs <pod>View Pod logs
kubectl exec -it <pod> -- /bin/bashOpen shell in a Pod
kubectl apply -f <file>Apply a YAML manifest
kubectl delete -f <file>Delete resources from a YAML manifest
kubectl scale deployment <name> --replicas=NScale a Deployment
kubectl rollout status deployment/<name>Check rollout progress
kubectl rollout undo deployment/<name>Rollback a Deployment
kubectl top nodesShow node resource usage
kubectl top podsShow Pod resource usage
kubectl config get-contextsList available cluster contexts
kubectl cluster-infoDisplay cluster endpoint information

Summary

You now have a working Kubernetes cluster deployed with kubeadm, complete with a control plane, worker nodes, a CNI network plugin, and your first application running with exposed services. This is the same foundational architecture used in production Kubernetes environments worldwide.

Key takeaways:

  • Kubernetes orchestrates containers across multiple nodes with automated scheduling, scaling, and self-healing
  • The control plane (API server, etcd, scheduler, controller manager) manages cluster state while worker nodes run your workloads
  • kubeadm is the official tool for bootstrapping production-grade clusters
  • Deployments provide declarative management of Pods with rolling updates and rollbacks
  • Services (ClusterIP, NodePort, LoadBalancer) give Pods stable network endpoints
  • Always use YAML manifests for production workloads to enable version control and reproducibility

For containerization fundamentals that complement this guide, see our tutorials on How to Install Docker on Ubuntu and Docker Compose: Practical Guide for Sysadmins.