Managed Kubernetes is great. But understanding how to build a cluster from scratch — node by node, component by component — is what separates engineers who use Kubernetes from engineers who truly know it.
Kubernetes Zero to Hero · Bonus Episode ·
Most engineers learning Kubernetes start with minikube or Docker Desktop — a single-node cluster on their laptop. That's perfect for learning concepts. But there comes a moment when you need to feel what a real multi-node cluster is like: bootstrapping control planes, joining worker nodes, configuring networking, and watching the whole thing come alive from scratch.
That moment is this article.
We’re going to build a fully functional, 3-node Kubernetes cluster on AWS EC2 using kubeadm — the official Kubernetes cluster bootstrapping tool. One control plane node. Two worker nodes. Real networking. Real certificates. The same foundation that powers production clusters at scale.
By the end, you’ll have a running cluster you built yourself — and the understanding of every step that got you there.
What We’re Building
┌─────────────────────────────────────────────────┐
│ AWS VPC │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Public Subnet │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Control │ │ Worker │ │ │
│ │ │ Plane │ │ Node 1 │ │ │
│ │ │ t3.medium │ │ t3.medium │ │ │
│ │ │ 2 vCPU │ │ 2 vCPU │ │ │
│ │ │ 4GB RAM │ │ 4GB RAM │ │ │
│ │ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ ┌─────────────┐ │ │
│ │ │ Worker │ │ │
│ │ │ Node 2 │ │ │
│ │ │ t3.medium │ │ │
│ │ │ 2 vCPU │ │ │
│ │ │ 4GB RAM │ │ │
│ │ └─────────────┘ │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
What we’ll use:
- 3 × EC2 t3.medium instances (Ubuntu 22.04 LTS)
- kubeadm to bootstrap the cluster
- containerd as the container runtime
- Calico as the CNI network plugin
- kubectl to interact with the cluster
Estimated cost: Running 3 × t3.medium for a few hours costs less than $1. Terminate them when you’re done.
Prerequisites
- An AWS account with permissions to create EC2 instances, Security Groups, and Key Pairs
- AWS CLI configured locally (optional but useful)
- Basic familiarity with SSH and Linux commands
- If you’ve been following this series — you’re ready
Step 1: Launch Your EC2 Instances
1.1 Create a Security Group
In the AWS Console → EC2 → Security Groups → Create Security Group.
Name it k8s-cluster-sg and add the following inbound rules:
Type → Protocol → Port Range → Source → Purpose
SSH → TCP → 22 → Your IP → Remote access
Custom TCP → TCP → 6443 → 0.0.0.0/0 → Kubernetes API Server
Custom TCP → TCP → 2379–2380 → Security Group itself → etcd peer communication
Custom TCP → TCP → 10250 → Security Group itself → kubelet API
Custom TCP → TCP → 10251 → Security Group itself → kube-scheduler
Custom TCP → TCP → 10252 → Security Group itself → kube-controller-manager
Custom TCP → TCP → 30000–32767 → 0.0.0.0/0 → NodePort Services
All traffic → All → All → Security Group itself → Inter-node communication
💡 The last rule is critical. Adding the security group as its own source allows all nodes in the group to communicate freely with each other — essential for pod networking, etcd replication, and kubelet communication.
1.2 Launch the Instances
Launch 3 EC2 instances with these settings:
- AMI: Ubuntu Server 22.04 LTS (64-bit x86)
- Instance type: t3.medium
- Key pair: Create or use an existing key pair — you’ll need it for SSH
- Security group: Select
k8s-cluster-sg - Storage: 20 GB gp3 root volume (minimum — 8GB is not enough)
- Number of instances: 3
After launch, tag them clearly:
- Instance 1:
Name = k8s-control-plane - Instance 2:
Name = k8s-worker-1 - Instance 3:
Name = k8s-worker-2
Note down the private IP addresses of all three instances — you’ll need them.
Step 2: Prepare All Three Nodes
SSH into each node and run the following commands. Do this on all three nodes — control plane and both workers.
# SSH into each instance
ssh -i your-key.pem ubuntu@<PUBLIC-IP>
2.1 Set Hostnames
On the control plane node:
sudo hostnamectl set-hostname k8s-control-plane
On worker node 1:
sudo hostnamectl set-hostname k8s-worker-1
On worker node 2:
sudo hostnamectl set-hostname k8s-worker-2
2.2 Update /etc/hosts on All Nodes
On every node, add entries so nodes can resolve each other by name. Replace the IPs with your actual private IPs:
sudo tee -a /etc/hosts <<EOF
# Kubernetes cluster nodes
10.0.1.10 k8s-control-plane
10.0.1.11 k8s-worker-1
10.0.1.12 k8s-worker-2
EOF
2.3 Disable Swap
Kubernetes requires swap to be disabled. It will refuse to start if swap is on.
# Disable swap immediately
sudo swapoff -a
# Disable swap permanently — comment out the swap line
sudo sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab# Verify swap is off
free -h
# Swap row should show: 0B 0B 0B
2.4 Enable Required Kernel Modules
# Load modules now
sudo modprobe overlay
sudo modprobe br_netfilter
# Make them load on boot
sudo tee /etc/modules-load.d/k8s.conf <<EOF
overlay
br_netfilter
EOF# Verify modules are loaded
lsmod | grep br_netfilter
lsmod | grep overlay
2.5 Configure Kernel Parameters for Networking
sudo tee /etc/sysctl.d/k8s.conf <<EOF
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
sysctl net.bridge.bridge-nf-call-iptables
# Should return: net.bridge.bridge-nf-call-iptables = 1
Step 3: Install containerd (All Nodes)
Kubernetes needs a container runtime. We’ll use containerd — the industry standard, used by EKS, GKE, and AKS.
# Install prerequisites
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release
# Add Docker's official GPG key (containerd is distributed by Docker)
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg# Add Docker repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null# Install containerd
sudo apt-get update
sudo apt-get install -y containerd.io# Generate default config
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml# Enable SystemdCgroup — critical for kubeadm clusters
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml# Verify the change was applied
grep SystemdCgroup /etc/containerd/config.toml
# Should show: SystemdCgroup = true# Restart and enable containerd
sudo systemctl restart containerd
sudo systemctl enable containerd# Verify it's running
sudo systemctl status containerd
⚠️
SystemdCgroup = trueis not optional. Without it, kubelet and containerd use different cgroup drivers, causing subtle instability and nodeNotReadyerrors that are painful to debug. Always set this.
Step 4: Install kubeadm, kubelet, and kubectl (All Nodes)
# Add Kubernetes apt repository
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.29/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.29/deb/ /' | \
sudo tee /etc/apt/sources.list.d/kubernetes.list# Install the three tools
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl# Pin their versions — prevent accidental upgrades
sudo apt-mark hold kubelet kubeadm kubectl# Verify installation
kubeadm version
kubectl version --client
kubelet --version
kubeadm version: &version.Info{Major:"1", Minor:"29", ...}
Client Version: version.Info{Major:"1", Minor:"29", ...}
Kubernetes v1.29.0💡
apt-mark holdis important. Kubernetes components must be upgraded deliberately and in the right order — not auto-updated byapt upgrade. Pinning versions protects you from an unintended cluster-breaking upgrade.
Step 5: Initialise the Control Plane
This step runs on the control plane node only.
sudo kubeadm init \
--pod-network-cidr=192.168.0.0/16 \
--apiserver-advertise-address=<CONTROL-PLANE-PRIVATE-IP> \
--node-name=k8s-control-plane
Replace <CONTROL-PLANE-PRIVATE-IP> with your actual private IP (e.g. 10.0.1.10).
The --pod-network-cidr=192.168.0.0/16 is required by Calico — our CNI plugin of choice.
This takes 2–3 minutes. When it completes, you’ll see something like:
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/configYou can now join any number of machines by running the following on each node
as root: kubeadm join 10.0.1.10:6443 --token abc123.xyz789 \
--discovery-token-ca-cert-hash sha256:abcdef1234567890...
Copy that kubeadm join command — you'll need it for the worker nodes. It expires after 24 hours.
5.1 Configure kubectl Access
# Run these as the ubuntu user (not root)
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 can talk to the cluster
kubectl get nodes
NAME STATUS ROLES AGE VERSION
k8s-control-plane NotReady control-plane 60s v1.29.0
NotReady is expected — the control plane has no CNI plugin yet. We'll fix that next.
Step 6: Install the Calico CNI Plugin
On the control plane node only.
Without a CNI plugin, no pod can communicate with any other pod. The cluster exists but is completely non-functional.
# Install Calico
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.27.0/manifests/calico.yaml
# Watch Calico pods come up
kubectl get pods -n kube-system --watch
# Wait until all calico-* pods are Running# Once Calico is ready, the control plane node becomes Ready
kubectl get nodes
NAME STATUS ROLES AGE VERSION
k8s-control-plane Ready control-plane 3m v1.29.0
Step 7: Join the Worker Nodes
SSH into each worker node and run the kubeadm join command you copied in Step 5:
# On k8s-worker-1 and k8s-worker-2
sudo kubeadm join 10.0.1.10:6443 \
--token abc123.xyz789 \
--discovery-token-ca-cert-hash sha256:abcdef1234567890...
If your token has expired (after 24 hours), generate a new one on the control plane:
# On control plane — generate a new join command
kubeadm token create --print-join-command
Verify the Cluster
Back on the control plane:
kubectl get nodes
NAME STATUS ROLES AGE VERSION
k8s-control-plane Ready control-plane 6m v1.29.0
k8s-worker-1 Ready <none> 2m v1.29.0
k8s-worker-2 Ready <none> 90s v1.29.0
Three nodes. All Ready. That’s your cluster.
# Check all system pods are healthy
kubectl get pods -n kube-system
NAME READY STATUS RESTARTS AGE
calico-kube-controllers-7d568c557f-x9k2p 1/1 Running 0 4m
calico-node-4jxzq 1/1 Running 0 4m
calico-node-8p9wk 1/1 Running 0 2m
calico-node-rvn2t 1/1 Running 0 2m
coredns-5dd5756b68-4xvqk 1/1 Running 0 6m
coredns-5dd5756b68-8wmlk 1/1 Running 0 6m
etcd-k8s-control-plane 1/1 Running 0 6m
kube-apiserver-k8s-control-plane 1/1 Running 0 6m
kube-controller-manager-k8s-control-plane 1/1 Running 0 6m
kube-proxy-4jxzq 1/1 Running 0 6m
kube-proxy-8p9wk 1/1 Running 0 2m
kube-proxy-rvn2t 1/1 Running 0 2m
kube-scheduler-k8s-control-plane 1/1 Running 0 6m
Every component from Episode 02 — etcd, kube-apiserver, kube-controller-manager, kube-scheduler, coredns, calico (CNI), kube-proxy — is right there in that list. You built this.
Step 8: Smoke Test — Deploy a Real Application
Let’s prove the cluster works end to end.
# Deploy nginx
kubectl create deployment nginx --image=nginx:1.25 --replicas=3
# Expose it as a NodePort service
kubectl expose deployment nginx --type=NodePort --port=80# Check everything is running
kubectl get pods -o wide
NAME READY STATUS NODE
nginx-7d4b9c8d6f-x2k9f 1/1 Running k8s-worker-1
nginx-7d4b9c8d6f-p3mzr 1/1 Running k8s-worker-2
nginx-7d4b9c8d6f-hn7wq 1/1 Running k8s-worker-1# Get the NodePort assigned
kubectl get service nginx
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
nginx NodePort 10.96.123.45 <none> 80:31456/TCP 10s# Test from your laptop using any worker node's public IP
curl http://<WORKER-NODE-PUBLIC-IP>:31456
# <!DOCTYPE html> <html> ... Welcome to nginx! ...
Your application is live. Running on EC2. Across multiple nodes. Load balanced. Fully managed by Kubernetes.
Step 9: Configure kubectl from Your Local Machine (Optional)
Instead of SSH-ing into the control plane every time, copy the kubeconfig to your laptop:
# On your local machine
mkdir -p ~/.kube
# Copy the config from the control plane
scp -i your-key.pem ubuntu@<CONTROL-PLANE-PUBLIC-IP>:~/.kube/config ~/.kube/config# Edit the server address — change the private IP to the public IP
sed -i 's/<CONTROL-PLANE-PRIVATE-IP>/<CONTROL-PLANE-PUBLIC-IP>/' ~/.kube/config# Verify remote access works
kubectl get nodes
NAME STATUS ROLES AGE VERSION
k8s-control-plane Ready control-plane 15m v1.29.0
k8s-worker-1 Ready <none> 10m v1.29.0
k8s-worker-2 Ready <none> 10m v1.29.0
⚠️ The API Server’s port 6443 must be open in your Security Group to your local IP. Go back to the Security Group and add an inbound rule: TCP 6443 from your IP.
Step 10: Install the Metrics Server (Optional but Recommended)
The Metrics Server enables kubectl top — resource usage visibility for nodes and pods.
# Install Metrics Server
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
# For self-managed clusters (not managed cloud k8s), add the --kubelet-insecure-tls flag
kubectl patch deployment metrics-server -n kube-system \
--type='json' \
-p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]'# Wait for it to be ready
kubectl get pods -n kube-system | grep metrics-server# Check resource usage
kubectl top nodes
NAME CPU(cores) CPU% MEMORY(bytes) MEMORY%
k8s-control-plane 185m 9% 1024Mi 27%
k8s-worker-1 62m 3% 512Mi 13%
k8s-worker-2 58m 2% 498Mi 13%kubectl top pods
What Just Happened — The Full Picture
Let’s connect this back to what we learned in Episode 02.
When you ran kubeadm init, it:
- Generated a self-signed Certificate Authority and all the TLS certificates the cluster components use to authenticate with each other
- Wrote a static pod manifest for etcd, kube-apiserver, kube-controller-manager, and kube-scheduler to
/etc/kubernetes/manifests/— the kubelet reads these and starts those pods automatically - Bootstrapped etcd with an empty cluster state
- Started the API Server and registered the first node
- Applied RBAC rules, kube-proxy, and CoreDNS as in-cluster resources
- Generated the kubeconfig file you copied to
~/.kube/config
When you ran kubeadm join, each worker:
- Used the token to authenticate with the API Server
- Received the cluster’s CA certificate (validated by the
--discovery-token-ca-cert-hash) - Started the kubelet and registered the node
- The Calico CNI plugin saw the new node and started a
calico-nodepod on it - The node transitioned from
NotReady→Ready
# See the static pod manifests kubeadm created
sudo ls /etc/kubernetes/manifests/
etcd.yaml
kube-apiserver.yaml
kube-controller-manager.yaml
kube-scheduler.yaml
# These are the actual pod definitions for your control plane components
sudo cat /etc/kubernetes/manifests/kube-apiserver.yaml
Common Issues and Fixes
Node stays in NotReady after joining
# Check kubelet logs on the worker node
sudo journalctl -u kubelet -f
# Common cause: CNI plugin not installed, or containerd not configured correctly
# Fix: verify SystemdCgroup = true in /etc/containerd/config.toml
grep SystemdCgroup /etc/containerd/config.toml
sudo systemctl restart containerd kubelet
kubeadm init fails with “port already in use”
# A previous failed init left state behind
sudo kubeadm reset -f
sudo rm -rf /etc/cni/net.d
sudo rm -rf $HOME/.kube
# Then re-run kubeadm init
Pods stuck in ContainerCreating
kubectl describe pod <pod-name>
# Look for: "network: failed to set bridge addr"
# Usually means CNI wasn't installed or is misconfigured
# Check Calico pods are all Running
kubectl get pods -n kube-system | grep calico
Cannot reach API server from kubectl
# Check the API server is running
sudo systemctl status kubelet
kubectl get pods -n kube-system | grep apiserver
# Check the port is open in your Security Group (6443)
# Check the server address in ~/.kube/config matches public IP
Forgot the join command
# On the control plane — regenerate it
kubeadm token create --print-join-command
Tearing Down Cleanly
When you’re done experimenting, clean up to avoid unnecessary charges.
# On each worker node — gracefully remove from cluster
kubectl drain k8s-worker-1 --ignore-daemonsets --delete-emptydir-data
kubectl delete node k8s-worker-1
kubectl drain k8s-worker-2 --ignore-daemonsets --delete-emptydir-data
kubectl delete node k8s-worker-2# On each worker — reset kubeadm state
sudo kubeadm reset -f
sudo rm -rf /etc/cni/net.d# Terminate all EC2 instances from the AWS Console
# or with AWS CLI:
aws ec2 terminate-instances --instance-ids <id1> <id2> <id3>
What You’ve Built
Take a moment to appreciate what just happened. You didn’t click “Create Cluster” in a console and wait for a managed service to do the work. You:
- Configured kernel modules and networking parameters from scratch
- Installed and configured a production-grade container runtime
- Bootstrapped a Kubernetes control plane with full TLS
- Joined worker nodes and watched them register
- Installed a CNI plugin and watched pod networking come alive
- Deployed a real application across multiple nodes
This is how EKS, GKE, and AKS work under the hood — with managed tooling wrapping exactly these steps. Now that you’ve done it manually, the managed services will make a lot more sense. And when something goes wrong in a managed cluster, you’ll know where to look.
Next Steps for This Cluster
Now that you have a real cluster, put it to use:
# Try the exercises from Episodes 03–06 on real EC2 nodes
# Deploy a multi-tier application (frontend + backend + database)
# Test self-healing — stop a worker node and watch pods reschedule
# Test rolling updates across real nodes
# Stop a worker node from the AWS Console
# Watch Kubernetes detect the failure and reschedule workloads:
kubectl get nodes --watch
kubectl get pods -o wide --watch
Stopping a worker node and watching Kubernetes reschedule its pods onto the remaining node — in a cluster you built yourself — is one of the most satisfying experiences in this entire series.
Resuming the Main Series
This was a detour to give you real infrastructure under your feet. We now return to the main series roadmap.
In Episode 07, we pick back up with Ingress Controllers and Load Balancing — exposing multiple services through a single entry point with path-based routing, host-based routing, and TLS termination. If you want to follow along on your EC2 cluster rather than minikube, we’ll show you how to install NGINX Ingress on a self-managed cluster too.
Episode Topic
EP 01 Introduction to Kubernetes
EP 02 Kubernetes Architecture & Components
EP 03 Pods, Deployments & ReplicaSets
EP 04 Services & Networking
EP 05 ConfigMaps, Secrets & Environment Config
EP 06 Storage: PersistentVolumes & StatefulSets
Bonus ← You are here Setting Up a Cluster with kubeadm on EC2
EP 07 Ingress Controllers & Load Balancing
EP 08 RBAC & Security Fundamentals
EP 09 Monitoring with Prometheus & Grafana
EP 10 Production Hardening & Helm Charts
There’s a version of Kubernetes learning where you only ever use managed clusters and never touch the control plane. That version produces engineers who can deploy but can’t diagnose. You’ve taken a different path. See you in Episode 07.