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

Domains
InfrastructureDevOps
Technologies
TERRAFORMKUBERNETESAWSEKS

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.

◈ DIAGRAM
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 Ingress
Problem 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:

Bash
## Install Terraform (use tfenv for version management)
git clone https://github.com/tfutils/tfenv.git ~/.tfenv
export PATH="$HOME/.tfenv/bin:$PATH"
tfenv install 1.7.0
tfenv use 1.7.0
terraform --version
## Install AWS CLI v2
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zip
unzip awscliv2.zip && sudo ./aws/install
aws --version
## Install kubectl
curl -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 --client

Configure AWS credentials using a dedicated IAM role (never use root):

Bash
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 ID

Create the S3 backend and DynamoDB lock table:

Bash
## 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 bucket
aws s3api put-bucket-versioning \
--bucket eks-terraform-state-YOUR_ACCOUNT_ID \
--versioning-configuration Status=Enabled
## Create DynamoDB lock table
aws 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-1
Remember

The 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:

Bash
mkdir -p eks-terraform/{modules/vpc,modules/eks,modules/node-group,environments/production}
cd eks-terraform

Create modules/vpc/main.tf:

HCL
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]
}
Security

EKS 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:

HCL
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 encryption
resource "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 plane
resource "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 logs
resource "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:

HCL
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" })
}
}
Tip

Set capacity_type = "SPOT" for non-production node groups to reduce costs by up to 70%. Always use ON_DEMAND for production stateful workloads.

Step 5: Wire Everything Together in the Production Environment

Create environments/production/main.tf:

HCL
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

Bash
## Initialise Terraform
cd environments/production
terraform init
## Preview what will be created (always review before applying)
terraform plan -out=tfplan
## Apply — this takes approximately 12-15 minutes
terraform apply tfplan
## Configure kubectl to connect to the new cluster
aws eks update-kubeconfig \
--region ap-south-1 \
--name YOUR_CLUSTER_NAME
## Verify nodes are Ready
kubectl get nodes -o wide

Step 7: Install the AWS Load Balancer Controller

The AWS Load Balancer Controller provisions ALBs automatically when you create a Kubernetes Ingress resource.

Bash
## Install Helm
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
## Add the EKS chart repository
helm repo add eks https://aws.github.io/eks-charts
helm repo update
## Create the IAM policy for the controller
aws iam create-policy \
--policy-name AWSLoadBalancerControllerIAMPolicy \
--policy-document file://iam-policy.json
## Create the service account with IRSA
eksctl 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 controller
helm 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
## Verify
kubectl get deployment -n kube-system aws-load-balancer-controller
Common Mistake

Forgetting 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:

Bash
## 1. Verify all nodes are Ready
kubectl get nodes
## Expected: All nodes show STATUS = Ready
## 2. Check system pods are running
kubectl 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-end
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-test
spec:
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: v1
kind: Service
metadata:
name: nginx-test
spec:
selector:
app: nginx-test
ports:
* port: 80
targetPort: 80
type: LoadBalancer
EOF
## 4. Wait for the LoadBalancer to provision (1-2 minutes)
kubectl get svc nginx-test --watch
## 5. Test the endpoint
curl http://$(kubectl get svc nginx-test -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
## Expected: nginx welcome page HTML
## 6. Verify Terraform state is clean
terraform plan
## Expected: No changes. Infrastructure is up-to-date.
## 7. Clean up test resources
kubectl delete deployment nginx-test
kubectl delete svc nginx-test