Amazon EKS — Terraform EKS Hands-On Lab
Deploy a complete EKS cluster using the modular Terraform approach.
Prerequisites:
- Terraform >= 1.5 installed
- AWS CLI configured with credentials
- kubectl installed
Step 1 — Create Project Structure
mkdir -p terraform-eks/{modules/{vpc,eks,addons},environments/dev}
cd terraform-eks
Step 2 — Create the VPC Module
cat > modules/vpc/main.tf << 'EOF'
variable "cluster_name" { type = string }
variable "vpc_cidr" { type = string; default = "10.0.0.0/16" }
data "aws_availability_zones" "available" { state = "available" }
locals {
azs = slice(data.aws_availability_zones.available.names, 0, 3)
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "${var.cluster_name}-vpc" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.cluster_name}-igw" }
}
resource "aws_subnet" "public" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 100)
availability_zone = local.azs[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.cluster_name}-public-${local.azs[count.index]}"
"kubernetes.io/role/elb" = "1"
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
resource "aws_subnet" "private" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 1)
availability_zone = local.azs[count.index]
tags = {
Name = "${var.cluster_name}-private-${local.azs[count.index]}"
"kubernetes.io/role/internal-elb" = "1"
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
resource "aws_eip" "nat" {
domain = "vpc"
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
depends_on = [aws_internet_gateway.main]
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route { cidr_block = "0.0.0.0/0"; gateway_id = aws_internet_gateway.main.id }
}
resource "aws_route_table_association" "public" {
count = 3
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route { cidr_block = "0.0.0.0/0"; nat_gateway_id = aws_nat_gateway.main.id }
}
resource "aws_route_table_association" "private" {
count = 3
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
output "vpc_id" { value = aws_vpc.main.id }
output "private_subnet_ids" { value = aws_subnet.private[*].id }
output "public_subnet_ids" { value = aws_subnet.public[*].id }
EOF
Step 3 — Create the Environment Configuration
cat > environments/dev/main.tf << 'EOF'
terraform {
required_version = ">= 1.5"
required_providers {
aws = { source = "hashicorp/aws"; version = "~> 5.0" }
}
}
provider "aws" {
region = "us-east-1"
default_tags {
tags = { Environment = "dev"; ManagedBy = "terraform" }
}
}
variable "cluster_name" { default = "lab-eks" }
variable "cluster_version" { default = "1.31" }
module "vpc" {
source = "../../modules/vpc"
cluster_name = var.cluster_name
}
# ── EKS Cluster ──
resource "aws_iam_role" "cluster" {
name = "${var.cluster_name}-cluster"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"; Effect = "Allow"
Principal = { Service = "eks.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "cluster" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
role = aws_iam_role.cluster.name
}
resource "aws_eks_cluster" "main" {
name = var.cluster_name
version = var.cluster_version
role_arn = aws_iam_role.cluster.arn
vpc_config {
subnet_ids = module.vpc.private_subnet_ids
endpoint_private_access = true
endpoint_public_access = true
}
access_config {
authentication_mode = "API_AND_CONFIG_MAP"
bootstrap_cluster_creator_admin_permissions = true
}
depends_on = [aws_iam_role_policy_attachment.cluster]
}
# ── Node Group ──
resource "aws_iam_role" "node" {
name = "${var.cluster_name}-node"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"; Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "node" {
for_each = toset([
"arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy",
"arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy",
"arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
])
policy_arn = each.value
role = aws_iam_role.node.name
}
resource "aws_eks_node_group" "main" {
cluster_name = aws_eks_cluster.main.name
node_group_name = "${var.cluster_name}-main"
node_role_arn = aws_iam_role.node.arn
subnet_ids = module.vpc.private_subnet_ids
instance_types = ["t3.medium"]
scaling_config {
desired_size = 2
min_size = 1
max_size = 3
}
depends_on = [aws_iam_role_policy_attachment.node]
}
# ── Outputs ──
output "cluster_name" { value = aws_eks_cluster.main.name }
output "kubeconfig" { value = "aws eks update-kubeconfig --name ${aws_eks_cluster.main.name} --region us-east-1" }
EOF
Step 4 — Deploy
cd environments/dev
terraform init
terraform plan
terraform apply -auto-approve
# Wait ~15-20 minutes for cluster creation
Step 5 — Verify
# Configure kubectl
eval $(terraform output -raw kubeconfig)
# Check nodes
kubectl get nodes -o wide
# Check system pods
kubectl get pods -n kube-system
# Deploy a test workload
kubectl create deployment nginx --image=nginx:latest --replicas=2
kubectl expose deployment nginx --port=80 --type=ClusterIP
kubectl get pods -w
Step 6 — Cleanup
# Delete test workload
kubectl delete deployment nginx
kubectl delete svc nginx
# Destroy infrastructure
terraform destroy -auto-approve
Key Takeaways
| Concept | Learned |
|---|---|
| Modular structure | VPC module separated from EKS |
| IAM roles | Cluster role and node role are different |
| Subnet tagging | Required for EKS auto-discovery |
| OIDC provider | Foundation for IRSA |
lifecycle.ignore_changes |
Let autoscaler manage desired_size |