Deploy a Production EKS Cluster with Terraform
Provision a production-ready Amazon EKS cluster using Terraform with VPC networking, IAM roles, node groups, and autoscaling.
Domains & Technologies
Blueprint Walkthrough
Architecture Overview
This project provisions a fully production-grade Amazon EKS cluster on AWS using Terraform as the infrastructure as code tool. You will build the complete networking foundation (VPC, subnets, route tables, NAT gateways), the EKS control plane, managed node groups with autoscaling, IAM roles using IRSA (IAM Roles for Service Accounts), and a working NGINX Ingress Controller exposed via an AWS Load Balancer.
This is the same foundational architecture used by teams at Zerodha, Swiggy, and Razorpay for running containerised workloads at scale.
Region: ap-south-1 +---------------------------------------------------------+ | VPC | | +--------------+ +--------------+ | | | Public | | Private | | | | Subnet AZ-a | | Subnet AZ-a | | | | (NAT GW) | | (Node Group) | | | +--------------+ +--------------+ | | +--------------+ +--------------+ | | | Public | | Private | | | | Subnet AZ-b | | Subnet AZ-b | | | | (ALB) | | (Node Group) | | | +--------------+ +--------------+ | | | | +--------------------+ | | | EKS Control Plane | | | | (AWS Managed) | | | +--------------------+ | +---------------------------------------------------------+ | | Internet Gateway Private Node Groups | (t3.medium, 2–5 nodes) ALB IngressProblem Solved
Manually creating an EKS cluster through the AWS console is not repeatable, not auditable, and breaks when someone makes a change through the console that is not tracked anywhere. Every environment — dev, staging, production — ends up slightly different, which causes "works in staging, broken in prod" situations.
This project solves that entirely. One Terraform configuration, version-controlled in Git, produces identical EKS clusters in any environment. The cluster includes production security defaults — nodes in private subnets, IRSA instead of node-level IAM permissions, IMDSv2 enforced, and security group least-privilege rules.
Step-by-Step Implementation Guide
Step 1: Set Up Your Terraform Workspace
Before writing any infrastructure code, set up the workspace correctly. This prevents state corruption and credential exposure.
Install required tools:
## Install Terraform (use tfenv for version management)git clone https://github.com/tfutils/tfenv.git ~/.tfenvexport PATH="$HOME/.tfenv/bin:$PATH"tfenv install 1.7.0tfenv use 1.7.0terraform --version ## Install AWS CLI v2curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zipunzip awscliv2.zip && sudo ./aws/installaws --version ## Install kubectlcurl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"chmod +x kubectl && sudo mv kubectl /usr/local/bin/kubectl version --clientConfigure AWS credentials using a dedicated IAM role (never use root):
aws configure## Enter: Access Key ID, Secret Access Key, Region (ap-south-1), Output (json)aws sts get-caller-identity # Verify — should return your account IDCreate the S3 backend and DynamoDB lock table:
## Create S3 bucket for state (replace YOUR_ACCOUNT_ID)aws s3api create-bucket \ --bucket eks-terraform-state-YOUR_ACCOUNT_ID \ --region ap-south-1 \ --create-bucket-configuration LocationConstraint=ap-south-1 ## Enable versioning on the state bucketaws s3api put-bucket-versioning \ --bucket eks-terraform-state-YOUR_ACCOUNT_ID \ --versioning-configuration Status=Enabled ## Create DynamoDB lock tableaws dynamodb create-table \ --table-name terraform-eks-lock \ --attribute-definitions AttributeName=LockID,AttributeType=S \ --key-schema AttributeName=LockID,KeyType=HASH \ --billing-mode PAY_PER_REQUEST \ --region ap-south-1RememberThe S3 bucket name must be globally unique. Add your AWS account ID as a suffix to guarantee uniqueness.
Step 2: Write the VPC Module
Create the directory structure first:
mkdir -p eks-terraform/{modules/vpc,modules/eks,modules/node-group,environments/production}cd eks-terraformCreate modules/vpc/main.tf:
terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } }} resource "aws_vpc" "main" { cidr_block = var.vpc_cidr enable_dns_hostnames = true enable_dns_support = true tags = merge(var.tags, { Name = "${var.cluster_name}-vpc" })} resource "aws_subnet" "public" { count = length(var.availability_zones) vpc_id = aws_vpc.main.id cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index) availability_zone = var.availability_zones[count.index] map_public_ip_on_launch = true tags = merge(var.tags, { Name = "${var.cluster_name}-public-${var.availability_zones[count.index]}" "kubernetes.io/role/elb" = "1" # Required for ALB Ingress Controller })} resource "aws_subnet" "private" { count = length(var.availability_zones) vpc_id = aws_vpc.main.id cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index + 4) availability_zone = var.availability_zones[count.index] tags = merge(var.tags, { Name = "${var.cluster_name}-private-${var.availability_zones[count.index]}" "kubernetes.io/role/internal-elb" = "1" # Required for internal ALB })} resource "aws_internet_gateway" "main" { vpc_id = aws_vpc.main.id tags = merge(var.tags, { Name = "${var.cluster_name}-igw" })} resource "aws_eip" "nat" { count = length(var.availability_zones) domain = "vpc" tags = merge(var.tags, { Name = "${var.cluster_name}-nat-eip-${count.index}" })} resource "aws_nat_gateway" "main" { count = length(var.availability_zones) allocation_id = aws_eip.nat[count.index].id subnet_id = aws_subnet.public[count.index].id tags = merge(var.tags, { Name = "${var.cluster_name}-nat-${count.index}" }) depends_on = [aws_internet_gateway.main]}SecurityEKS worker nodes must go in private subnets. Never place nodes in public subnets — this exposes the kubelet API and node metadata endpoint to the internet.
Step 3: Write the EKS Cluster Module
Create modules/eks/main.tf:
resource "aws_eks_cluster" "main" { name = var.cluster_name version = var.kubernetes_version role_arn = aws_iam_role.cluster.arn vpc_config { subnet_ids = var.private_subnet_ids endpoint_private_access = true endpoint_public_access = true # Set to false for fully private clusters public_access_cidrs = var.allowed_cidr_blocks } # Enable envelope encryption for Kubernetes secrets encryption_config { provider { key_arn = aws_kms_key.eks.arn } resources = ["secrets"] } enabled_cluster_log_types = [ "api", "audit", "authenticator", "controllerManager", "scheduler" ] depends_on = [ aws_iam_role_policy_attachment.cluster_AmazonEKSClusterPolicy, aws_cloudwatch_log_group.eks ] tags = var.tags} ## KMS key for secret encryptionresource "aws_kms_key" "eks" { description = "EKS Secret Encryption Key - ${var.cluster_name}" deletion_window_in_days = 7 enable_key_rotation = true tags = var.tags} ## IAM role for the EKS control planeresource "aws_iam_role" "cluster" { name = "${var.cluster_name}-cluster-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "eks.amazonaws.com" } }] }) tags = var.tags} resource "aws_iam_role_policy_attachment" "cluster_AmazonEKSClusterPolicy" { policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy" role = aws_iam_role.cluster.name} ## CloudWatch log group for control plane logsresource "aws_cloudwatch_log_group" "eks" { name = "/aws/eks/${var.cluster_name}/cluster" retention_in_days = 30 tags = var.tags}Step 4: Configure Managed Node Groups with IRSA
Create modules/node-group/main.tf:
resource "aws_eks_node_group" "main" { cluster_name = var.cluster_name node_group_name = "${var.cluster_name}-${var.node_group_name}" node_role_arn = aws_iam_role.node.arn subnet_ids = var.private_subnet_ids instance_types = var.instance_types capacity_type = var.capacity_type # ON_DEMAND or SPOT scaling_config { desired_size = var.desired_size max_size = var.max_size min_size = var.min_size } update_config { max_unavailable = 1 } # Launch template for IMDSv2 enforcement launch_template { id = aws_launch_template.node.id version = aws_launch_template.node.latest_version } labels = var.node_labels lifecycle { ignore_changes = [scaling_config[0].desired_size] } depends_on = [ aws_iam_role_policy_attachment.node_AmazonEKSWorkerNodePolicy, aws_iam_role_policy_attachment.node_AmazonEKS_CNI_Policy, aws_iam_role_policy_attachment.node_AmazonEC2ContainerRegistryReadOnly ] tags = var.tags} ## Launch template enforcing IMDSv2 (prevents SSRF attacks on metadata endpoint)resource "aws_launch_template" "node" { name_prefix = "${var.cluster_name}-node-" metadata_options { http_endpoint = "enabled" http_tokens = "required" # Forces IMDSv2 http_put_response_hop_limit = 1 } tag_specifications { resource_type = "instance" tags = merge(var.tags, { Name = "${var.cluster_name}-node" }) }}TipSet
capacity_type = "SPOT"for non-production node groups to reduce costs by up to 70%. Always useON_DEMANDfor production stateful workloads.
Step 5: Wire Everything Together in the Production Environment
Create environments/production/main.tf:
terraform { required_version = ">= 1.7.0" backend "s3" { bucket = "eks-terraform-state-YOUR_ACCOUNT_ID" key = "production/eks/terraform.tfstate" region = "ap-south-1" encrypt = true dynamodb_table = "terraform-eks-lock" }} provider "aws" { region = var.aws_region} module "vpc" { source = "../../modules/vpc" cluster_name = var.cluster_name vpc_cidr = "10.0.0.0/16" availability_zones = ["ap-south-1a", "ap-south-1b"] tags = local.common_tags} module "eks" { source = "../../modules/eks" cluster_name = var.cluster_name kubernetes_version = "1.29" private_subnet_ids = module.vpc.private_subnet_ids allowed_cidr_blocks = ["YOUR_OFFICE_IP/32"] # Restrict public API access tags = local.common_tags} module "node_group" { source = "../../modules/node-group" cluster_name = module.eks.cluster_name node_group_name = "general" private_subnet_ids = module.vpc.private_subnet_ids instance_types = ["t3.medium"] capacity_type = "ON_DEMAND" desired_size = 2 min_size = 2 max_size = 5 tags = local.common_tags} locals { common_tags = { Environment = "production" ManagedBy = "terraform" Project = var.cluster_name }}Step 6: Deploy and Configure kubectl Access
## Initialise Terraformcd environments/productionterraform init ## Preview what will be created (always review before applying)terraform plan -out=tfplan ## Apply — this takes approximately 12-15 minutesterraform apply tfplan ## Configure kubectl to connect to the new clusteraws eks update-kubeconfig \ --region ap-south-1 \ --name YOUR_CLUSTER_NAME ## Verify nodes are Readykubectl get nodes -o wideStep 7: Install the AWS Load Balancer Controller
The AWS Load Balancer Controller provisions ALBs automatically when you create a Kubernetes Ingress resource.
## Install Helmcurl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash ## Add the EKS chart repositoryhelm repo add eks https://aws.github.io/eks-chartshelm repo update ## Create the IAM policy for the controlleraws iam create-policy \ --policy-name AWSLoadBalancerControllerIAMPolicy \ --policy-document file://iam-policy.json ## Create the service account with IRSAeksctl create iamserviceaccount \ --cluster=YOUR_CLUSTER_NAME \ --namespace=kube-system \ --name=aws-load-balancer-controller \ --role-name AmazonEKSLoadBalancerControllerRole \ --attach-policy-arn=arn:aws:iam::YOUR_ACCOUNT_ID:policy/AWSLoadBalancerControllerIAMPolicy \ --approve ## Install the controllerhelm install aws-load-balancer-controller eks/aws-load-balancer-controller \ -n kube-system \ --set clusterName=YOUR_CLUSTER_NAME \ --set serviceAccount.create=false \ --set serviceAccount.name=aws-load-balancer-controller ## Verifykubectl get deployment -n kube-system aws-load-balancer-controllerCommon MistakeForgetting to add the
kubernetes.io/role/elb: "1"tag on public subnets. The ALB controller uses this tag to discover which subnets to place load balancers in. If the tag is missing, Ingress resources will stay in a pending state with no ALB provisioned.
Validation & Testing
Run these checks to confirm your cluster is healthy and production-ready:
## 1. Verify all nodes are Readykubectl get nodes## Expected: All nodes show STATUS = Ready ## 2. Check system pods are runningkubectl get pods -n kube-system## Expected: coredns, aws-node, kube-proxy, aws-load-balancer-controller all Running ## 3. Deploy a test application to verify end-to-endkubectl apply -f - <<EOFapiVersion: apps/v1kind: Deploymentmetadata: name: nginx-testspec: replicas: 2 selector: matchLabels: app: nginx-test template: metadata: labels: app: nginx-test spec: containers: * name: nginx image: nginx:1.25-alpine ports: * containerPort: 80---apiVersion: v1kind: Servicemetadata: name: nginx-testspec: selector: app: nginx-test ports: * port: 80 targetPort: 80 type: LoadBalancerEOF ## 4. Wait for the LoadBalancer to provision (1-2 minutes)kubectl get svc nginx-test --watch ## 5. Test the endpointcurl http://$(kubectl get svc nginx-test -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')## Expected: nginx welcome page HTML ## 6. Verify Terraform state is cleanterraform plan## Expected: No changes. Infrastructure is up-to-date. ## 7. Clean up test resourceskubectl delete deployment nginx-testkubectl delete svc nginx-testVideos & Guides
TechWorld with Nana — Complete Kubernetes Course
Comprehensive Kubernetes fundamentals and EKS deployment walkthrough covering cluster setup, node groups, and production patterns.
AWS EKS Best Practices Guide
Official AWS EKS best practices covering security, networking, cluster autoscaling, and cost optimisation for production clusters.
Terraform AWS EKS Module Documentation
The official Terraform AWS EKS community module — understanding its structure helps when building custom modules as done in this project.