Building a Kubernetes Cluster on AWS with kubeadm

Ever wondered how Kubernetes clusters actually work under the hood? In this guide, I'll walk you through building your own Kubernetes cluster on AWS from scratch using kubeadm. This hands-on experience will give you a deeper understanding of Kubernetes architecture and help you appreciate what managed services like EKS do behind the scenes.
Whether you're preparing for a certification, building your homelab, or just curious about container orchestration, this step-by-step tutorial has got you covered!
What You'll Need
Don't worry—the setup is straightforward! Here's what you'll need before we begin:
- AWS Account: You'll need an active AWS account with billing enabled. If you're new to AWS, they offer free tier credits to get started!
- AWS CLI: The AWS Command Line Interface on your local machine (get it here)
- Quick setup:
aws configureto authenticate
- Quick setup:
- Appropriate Permissions: Your AWS account should be able to:
- Create and manage EC2 instances
- Configure VPC networks and security groups
- Manage IAM permissions
- Some Familiarity With:
- Basic Linux commands
- SSH connections
- Kubernetes fundamentals (though we'll explain as we go!)
Let's Build This Thing
Ready to get your hands dirty? We'll build this cluster step by step, starting with the infrastructure and working our way up to a fully functioning cluster. I'll explain what each command does and why it matters.
1. Get Your AWS CLI Ready
Run on: Your local machine
Configure the AWS CLI with your credentials and choose the region you will use for all resources in this guide.
aws configure
aws ec2 describe-regionsPick a region from the output and use it consistently for the rest of the guide.
2. Build Your Cloud Network Foundation
Run on: Your local machine
Create a dedicated VPC and public subnet, then attach an internet gateway and add a default route so instances can reach the internet. Update the availability zone if you chose a different region.
VPC_ID=$(aws ec2 create-vpc \
--cidr-block 10.0.0.0/16 \
--query 'Vpc.VpcId' \
--output text)
SUBNET_ID=$(aws ec2 create-subnet \
--vpc-id $VPC_ID \
--cidr-block 10.0.0.0/24 \
--availability-zone us-east-1a \
--query 'Subnet.SubnetId' \
--output text)
aws ec2 modify-subnet-attribute \
--subnet-id $SUBNET_ID \
--map-public-ip-on-launch
IGW_ID=$(aws ec2 create-internet-gateway \
--query 'InternetGateway.InternetGatewayId' \
--output text)
aws ec2 attach-internet-gateway \
--internet-gateway-id $IGW_ID \
--vpc-id $VPC_ID
ROUTE_TABLE_ID=$(aws ec2 describe-route-tables \
--filters "Name=vpc-id,Values=$VPC_ID" \
--query 'RouteTables[0].RouteTableId' \
--output text)
aws ec2 create-route \
--route-table-id $ROUTE_TABLE_ID \
--destination-cidr-block 0.0.0.0/0 \
--gateway-id $IGW_ID3. Lock Down Your Cluster Security
Run on: Your local machine
Create a security group for the cluster that allows SSH access and unrestricted node-to-node traffic. The example uses 0.0.0.0/0 for SSH; restrict it to your IP if you want tighter security.
SG_ID=$(aws ec2 create-security-group \
--group-name k8s-cluster \
--description 'Security group for Kubernetes cluster' \
--vpc-id $VPC_ID \
--query 'GroupId' \
--output text)
aws ec2 authorize-security-group-ingress \
--group-id $SG_ID \
--protocol tcp \
--port 22 \
--cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress \
--group-id $SG_ID \
--protocol -1 \
--source-group $SG_ID4. Spin Up Your Control Plane Instance
Run on: Your local machine
Launch a t3.medium Ubuntu 22.04 LTS instance for the control plane, wait for it to be running, and capture its public IP for SSH access.
CONTROL_PLANE_ID=$(aws ec2 run-instances \
--image-id ami-0030e4319cbf4dbf2 \
--instance-type t3.medium \
--security-group-ids $SG_ID \
--subnet-id $SUBNET_ID \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=k8s-control-plane}]' \
--query 'Instances[0].InstanceId' \
--output text)
echo "Waiting for instance to be ready..."
aws ec2 wait instance-running --instance-ids $CONTROL_PLANE_ID
CONTROL_PLANE_IP=$(aws ec2 describe-instances \
--instance-ids $CONTROL_PLANE_ID \
--query 'Reservations[0].Instances[0].PublicIpAddress' \
--output text)
echo "Control plane instance ready: $CONTROL_PLANE_IP"5. Jump Into the Control Plane
Run on: Your local machine
Connect to the control plane using EC2 Instance Connect, which handles authentication with your existing SSH key.
aws ec2-instance-connect ssh --instance-id $CONTROL_PLANE_ID --os-user ubuntuThe AWS CLI will automatically use your default SSH key (~/.ssh/id_rsa.pub). If you prefer standard SSH, send your public key (valid for 60 seconds) and connect directly.
aws ec2-instance-connect send-ssh-public-key \
--instance-id $CONTROL_PLANE_ID \
--instance-os-user ubuntu \
--ssh-public-key file://~/.ssh/id_rsa.pub
ssh ubuntu@$CONTROL_PLANE_IP6. Set Up the Control Plane Software
Run on: Control plane node (you're now SSH'd into the instance)
Prepare the OS for Kubernetes by enabling required kernel features, installing containerd, and installing the Kubernetes tools.
6.1. Enable Essential Kernel Features
Kubernetes requires specific kernel modules and network settings so the container runtime can handle bridged traffic.
Create a configuration file to load the necessary modules at boot and load them now:
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilterEnable IP forwarding and bridge networking. The sysctl command applies these changes immediately without requiring a reboot.
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 --system6.2. Install the Container Runtime
Install containerd from the official Ubuntu repositories.
sudo apt update
sudo apt install -y containerd6.3. Configure containerd for Kubernetes
By default, containerd does not use the systemd cgroup driver, which Kubernetes recommends for stability. Update the config and restart the service.
Create the directory and generate a clean configuration file:
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml > /dev/nullChange SystemdCgroup = false to true.
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.tomlApply the new configuration:
sudo systemctl restart containerd
sudo systemctl enable containerd6.4. Install the Kubernetes Toolkit
Add the Kubernetes package repository and install the components. The packages are held to prevent unintended upgrades.
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.35/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.35/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
sudo systemctl enable --now kubelet7. Bring the Control Plane to Life
Run on: Control plane node
Initialize the control plane with the chosen pod network CIDR.
sudo kubeadm init --pod-network-cidr=10.244.0.0/16Once initialization completes, configure kubectl access for your user account.
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/configThe initialization output includes a join token for worker nodes. You can save it, or generate a fresh one later.
Deploy Flannel as the CNI plugin to enable pod-to-pod networking.
kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.ymlEnable kubectl auto-completion for the current session and persist it in your bash profile.
source <(kubectl completion bash)
echo "source <(kubectl completion bash)" >> ~/.bashrcOptionally, create a shorthand alias k with the same completion behavior.
alias k=kubectl
complete -o default -F __start_kubectl k8. Provision Your First Worker Node
Run on: Your local machine (open a new terminal, keep control plane SSH session active)
Provision a worker node instance, wait for it to be running, and capture its public IP.
WORKER_ID=$(aws ec2 run-instances \
--image-id ami-0030e4319cbf4dbf2 \
--instance-type t3.medium \
--security-group-ids $SG_ID \
--subnet-id $SUBNET_ID \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=k8s-worker-1}]' \
--query 'Instances[0].InstanceId' \
--output text)
echo "Waiting for worker instance to be ready..."
aws ec2 wait instance-running --instance-ids $WORKER_ID
WORKER_IP=$(aws ec2 describe-instances \
--instance-ids $WORKER_ID \
--query 'Reservations[0].Instances[0].PublicIpAddress' \
--output text)
echo "Worker instance ready: $WORKER_IP"9. Jump Into the Worker Node
Run on: Your local machine
Connect to the worker node using EC2 Instance Connect.
aws ec2-instance-connect ssh --instance-id $WORKER_ID --os-user ubuntuAlternatively, use standard SSH by sending your public key (valid for 60 seconds) and connecting directly.
aws ec2-instance-connect send-ssh-public-key \
--instance-id $WORKER_ID \
--instance-os-user ubuntu \
--ssh-public-key file://~/.ssh/id_rsa.pub
ssh ubuntu@$WORKER_IP10. Set Up the Worker Node Software
Run on: Worker node (you're now SSH'd into the worker instance)
Repeat the system configuration and installation steps from section 6 on the worker node (sections 6.1 through 6.4).
11. Join the Worker to Your Cluster
Run on: Control plane node (first command), then worker node (second command)
Generate a fresh join command on the control plane, then run it on the worker node.
kubeadm token create --print-join-commandThis registers the worker node with the cluster.
12. Take Your Cluster for a Test Drive
Run on: Control plane node (kubectl commands) and your local machine (AWS commands)
Verify the cluster by deploying a simple nginx pod, inspecting its status, and optionally exposing it.
Deploy an nginx pod to your cluster:
kubectl run nginx-test --image=nginx:latest --port=80Check the status of your pod:
kubectl get podsYou should see output similar to:
NAME READY STATUS RESTARTS AGE
nginx-test 1/1 Running 0 30sView details about the running pod:
kubectl describe pod nginx-testIf you want to access nginx from within the cluster, create a service:
kubectl expose pod nginx-test --type=NodePort --port=80Check the service and assigned port:
kubectl get services nginx-testTo access nginx from your local machine, open the NodePort range in the security group and get a node's public IP.
Switch to your local machine terminal and update your security group to allow traffic on the NodePort range (30000-32767):
aws ec2 authorize-security-group-ingress \
--group-id $SG_ID \
--protocol tcp \
--port 30000-32767 \
--cidr 0.0.0.0/0Get the public IP address of your worker node or control plane:
aws ec2 describe-instances \
--filters "Name=tag:Name,Values=k8s-*" \
--query 'Reservations[*].Instances[*].[InstanceId,Tags[?Key==`Name`].Value|[0],PublicIpAddress]' \
--output tableNote the PublicIpAddress from the output, then switch back to the control plane node and get the NodePort assigned to your service:
kubectl get service nginx-test -o jsonpath='{.spec.ports[0].nodePort}'This will output a port number between 30000-32767. Access nginx by opening your browser to:
http://PUBLIC_IP:NODEPORTFor example, if your public IP is 54.123.45.67 and NodePort is 31234, visit:
http://54.123.45.67:31234You should see the "Welcome to nginx!" page.
On the control plane node, remove the nginx pod and service when you're done testing:
kubectl delete service nginx-test
kubectl delete pod nginx-testOn your local machine, if you created the NodePort security group rule, remove it as well:
aws ec2 revoke-security-group-ingress \
--group-id $SG_ID \
--protocol tcp \
--port 30000-32767 \
--cidr 0.0.0.0/013. Tear Down Your Kubernetes Playground
Run on: Your local machine
Tear down resources in reverse order: terminate the EC2 instances, delete the security group, detach and delete the internet gateway, then remove the subnet and VPC.
aws ec2 terminate-instances \
--instance-ids $CONTROL_PLANE_ID $WORKER_ID
aws ec2 wait instance-terminated \
--instance-ids $CONTROL_PLANE_ID $WORKER_ID
aws ec2 delete-security-group \
--group-id $SG_ID
aws ec2 detach-internet-gateway \
--internet-gateway-id $IGW_ID \
--vpc-id $VPC_ID
aws ec2 delete-internet-gateway \
--internet-gateway-id $IGW_ID
aws ec2 delete-subnet \
--subnet-id $SUBNET_ID
aws ec2 delete-vpc \
--vpc-id $VPC_IDA Word About Production Readiness
Before we wrap up, let's have an honest conversation: this cluster is perfect for learning and testing, but it's not production-ready. Think of this as your training ground—a safe space to experiment, break things, and learn!
Final Thoughts
Congratulations on building your own Kubernetes cluster! You've gained hands-on experience with container orchestration that many developers never get. Use this setup to experiment, test your applications, and deepen your understanding of how Kubernetes really works.
When you're ready for production, you'll appreciate managed services even more—but you'll also understand what's happening behind the scenes. And that knowledge? Invaluable.
Happy clustering! Feel free to reach out if you have questions or want to share your experience. Let's keep learning together!