Set Up a Three-Node Kubernetes Cluster on AWS with kubeadm and Terraform
I have always wanted a Kubernetes cluster of my own. Tools such as Minikube and Kind are excellent for learning, but they run a cluster locally. I wanted to practice with multiple virtual machines, with one control-plane node and two workers, in an environment that feels a little closer to a real cluster.
VirtualBox is one option, but not every computer has enough spare memory and CPU to run several virtual machines comfortably. A cloud provider such as AWS lets us create those machines on demand. The downside is that manually recreating the network, instances, and Kubernetes setup each time takes effort and leaves room for mistakes.
To make that easier, I put together a Terraform configuration that provisions
the AWS infrastructure and bootstraps Kubernetes with kubeadm. The goal is
to make a repeatable learning environment that is quick to create and easy to
destroy—not a production-ready cluster.
The Terraform code is in sarat532/tf-ec2-k8s-cluster.
What we are going to build
The default configuration creates three EC2 instances:
- One control-plane node.
- Two worker nodes.
Terraform also creates the VPC, subnets, internet gateway, routes, security
groups, and EC2 key pair. Instance user-data scripts install containerd and
Kubernetes packages, initialize the control plane with kubeadm, join the
workers, and install Calico for pod networking.
The worker count is configurable, so this setup can also create a larger or smaller learning cluster.
Before you start
You will need:
- An AWS account and AWS CLI credentials with permission to manage the resources listed above. Use an appropriately scoped IAM identity; do not put AWS access keys in Terraform files.
- Terraform CLI
~> 1.17.1. - OpenSSH tools (
ssh-keygenandssh). - A region where the configured availability zones are available. The
configuration currently uses
ap-south-1aandap-south-1b; update the subnet configuration if you use another region.
Configure an AWS CLI profile if you have not already done so, and confirm that it can access your account:
aws configure --profile k8s-admin
aws sts get-caller-identity --profile k8s-admin
Terraform uses the profile and region selected in its variables. The default
profile in the configuration is account-admin; the examples below override
it with k8s-admin.
A note about cost and security
EC2 instances and related AWS resources can incur charges for as long as they
exist. This configuration uses t3a.medium instances by default, and the
actual cost depends on the region, runtime, storage, and other AWS pricing.
Check current AWS pricing before applying, and destroy the cluster when you
finish.
Review the security groups before creating anything. The current general
ingress rules expose ports to 0.0.0.0/0, and the instances receive public IP
addresses. In particular, SSH and Kubernetes ports should not be left open to
the whole internet. Restrict SSH to your trusted IP range and limit
Kubernetes traffic to the networks that need it. Port 8080 is used
temporarily to pass the kubeadm join command and is restricted to the worker
security group, but the join token is still sensitive.
This is a learning setup. Its network exposure, single control plane, and bootstrap approach are not appropriate defaults for production.
1. Get the Terraform configuration
Clone the repository and move into its directory:
git clone https://github.com/sarat532/tf-ec2-k8s-cluster.git
cd tf-ec2-k8s-cluster
The important inputs have defaults in _variables.tf:
| Variable | Default | What it controls |
|---|---|---|
aws_region | ap-south-1 | AWS region |
aws_profile | account-admin | AWS CLI profile |
project_name | tf-ec2-k8s-cluster | Resource names and tags |
vpc_cidr | 10.190.32.0/26 | VPC address range |
instance_type | t3a.medium | Instance type |
worker_count | 2 | Number of worker nodes |
k8s_version | 1.36 | Kubernetes package repository version |
You can change defaults in _variables.tf, or override them locally with a
terraform.tfvars file. That file is ignored by Git and is a good place for
your account-specific choices:
aws_profile = "k8s-admin"
aws_region = "ap-south-1"
project_name = "learning-k8s"
vpc_cidr = "10.190.32.0/26"
instance_type = "t3a.medium"
worker_count = 2
k8s_version = "1.36"
Make sure your chosen VPC CIDR does not overlap with networks you need to
connect to. If you deploy in a different region, also update the availability
zones in vpc.tf.
2. Create an SSH key pair
From the repository root, create a key pair:
ssh-keygen -t rsa -b 4096 -f ec2-k8s
This creates ec2-k8s (the private key) and ec2-k8s.pub (the public key).
Terraform registers the public key with AWS. Keep the private key safe and
never commit it; the repository ignores the generated key files.
3. Initialize and review Terraform
Initialize the working directory to download the required provider plugins:
terraform init
Check that the configuration is valid, then inspect the proposed changes:
terraform validate
terraform plan
Read the plan carefully. Confirm the AWS account, region, instance count, network rules, and expected resources before continuing.
4. Create the cluster
Apply the configuration:
terraform apply
Terraform asks you to review and confirm the plan. Type yes only after
checking it.
Terraform creating the EC2 instances does not mean Kubernetes is ready yet. The user-data scripts continue installing and configuring Kubernetes after the instances start, so allow time for the bootstrap to finish. The exact duration depends on AWS and package downloads; treat the few-minute setup as an estimate, not a guarantee.
The control-plane script initializes Kubernetes with kubeadm and installs
Calico using the pod CIDR 172.16.0.0/16. It briefly serves a generated join
command to workers over a private cluster path. Each worker validates that
command before using it to join the control plane.
5. Connect and verify the nodes
Use the control-plane public IP shown in Terraform's output to connect:
ssh -i ec2-k8s ec2-user@<control-plane-public-ip>
On the control-plane instance, check the nodes:
kubectl get nodes -o wide
Wait until all three nodes show Ready. The ROLES column should identify
the control-plane and workers. For example:
NAME STATUS ROLES AGE VERSION
k8s-master Ready control-plane ... ...
k8s-worker-0 Ready worker ... ...
k8s-worker-1 Ready worker ... ...
The kubectl configuration is set up for both root and ec2-user on the
control-plane. If a node does not become ready or join, check that instance's
cloud-init output at /var/log/cloud-init-output.log and the EC2 system log.
Also confirm that the worker can reach the control-plane join endpoint and
that the security groups allow the required cluster traffic.
6. Destroy the cluster when you are finished
When you no longer need the cluster, return to the Terraform directory and run:
terraform destroy
Review the destroy plan and confirm it before proceeding. This removes the infrastructure managed by this Terraform configuration, including the EC2 instances and networking resources. Keep your Terraform state and SSH private key protected; they should not be shared publicly.
A few things to keep in mind
- The configuration installs a specific Kubernetes package repository
version, currently
1.36, and pins the Calico manifest separately. Changingk8s_versiondoes not guarantee compatibility with the operating system, container runtime, or Calico version. - The default network rules are intentionally something to review, not copy blindly. Tighten them before applying, especially for SSH and the Kubernetes API.
- Terraform makes the infrastructure repeatable, but it does not make the cluster highly available. This is a small cluster for learning and practice.
- Destroy the resources when you are done to avoid unnecessary cloud charges.
With the infrastructure automated, creating a multi-node cluster becomes a repeatable workflow: configure, plan, apply, learn, and destroy.
