Build Production AWS Infrastructure with Terraform

Provision a complete production-grade AWS infrastructure from scratch using Terraform - VPC, EKS cluster, IAM roles, IRSA, ECR, and remote state. The infrastructure that Capstone 1's application runs on in a real company.

Related Concepts & TermsIAM RoleRemote State

Domains & Technologies

Domains
CAPSTONEVPCIAMIRSAECRINFRASTRUCTURE-AS-CODEREMOTE-STATEAWS-CLOUD-ENGINEERING
Technologies
TERRAFORMAWSEKS

Blueprint Walkthrough

Before You Start — Read This First

In Capstone 1 you built and deployed a three-tier application to Kubernetes. But there was a gap — where did that Kubernetes cluster come from? How was the networking set up? Who configured the cloud permissions?

In most learning environments you are handed a pre-built cluster and told to use it. In a real company, a Platform Engineer provisions that cluster. They decide the network topology, set up the permissions, configure the container registry, and make sure everything is secure and reproducible.

This capstone fills that gap. You are going to provision real AWS infrastructure from scratch using Terraform — the same way companies like Razorpay, Zerodha, and Hotstar set up their production environments.

What you will build:

◈ DIAGRAM
AWS Account
├── VPC (your private network)
│ ├── Public Subnets (load balancers, NAT gateways)
│ └── Private Subnets (EKS nodes — not directly accessible from internet)
├── EKS Cluster (managed Kubernetes)
│ ├── Node Group (EC2 instances that run your pods)
│ └── IRSA (lets pods talk to AWS services without hardcoded keys)
├── ECR (container registry — stores your Docker images)
├── IAM Roles (permissions for EKS and pods)
└── S3 + DynamoDB (remote state — stores Terraform state safely)

After this capstone you will be able to:

  • Explain how VPCs, subnets, and routing work in AWS
  • Provision a production EKS cluster with Terraform
  • Understand why IRSA is critical for security
  • Manage Terraform state safely with S3 remote backend
  • Deploy Capstone 1's application onto infrastructure you provisioned yourself

Time to complete: 3-4 hours.

What you need before starting:

  • An AWS account (free tier works for most of this)
  • AWS CLI installed and configured (aws configure)
  • Terraform 1.5+ installed
  • kubectl installed
  • Basic familiarity with what was built in Capstone 1
Bash
## Verify tools are installed
aws --version
terraform --version
kubectl version --client
## Verify AWS credentials are configured
aws sts get-caller-identity
## Should show your AWS Account ID, User ID, and ARN
## If this fails, run: aws configure

What Is Terraform and Why Not Just Use the AWS Console?

You could click through the AWS Console and create everything manually. Many people do this when they are learning. The problem is:

It is not reproducible. Three months later when you need to create a staging environment that matches production, you have to remember every click, every setting, every option you chose. You will miss something. The environments will drift apart.

It is not reviewable. When a colleague changes a security group rule in the console, nobody else knows. There is no pull request, no code review, no history.

It is not recoverable. If someone accidentally deletes the cluster, recreating it from memory is slow, error-prone, and stressful.

Terraform solves all of this. You write infrastructure as code in .tf files. Terraform reads those files and creates (or updates or destroys) real AWS resources to match what the files describe. The files live in Git — so changes are tracked, reviewed, and recoverable.

Key Terraform concepts before starting:

Provider — a plugin that knows how to talk to a specific cloud or service. We use the AWS provider.

Resource — a piece of infrastructure. An aws_vpc is a resource. An aws_eks_cluster is a resource. Each resource block creates one real thing in AWS.

Module — a reusable collection of resources. Instead of writing 50 lines to create a VPC every time, you call a module that does it with 10 lines.

State — Terraform keeps track of what it created in a state file. This is how it knows what already exists and what needs to change.

Planterraform plan shows you what Terraform will do before it does it. Always run plan before apply.

Applyterraform apply actually creates or changes the infrastructure.


Part 1 — Project Structure and Remote State

Why Remote State?

By default Terraform stores state in a local file called terraform.tfstate. This works for learning but breaks in a team. If two engineers run terraform apply simultaneously with local state, they corrupt each other's state. If your laptop dies, the state is gone and Terraform no longer knows what it created.

Remote state stores the state file in S3 (safe, versioned, encrypted) and uses DynamoDB for locking (only one person can run apply at a time).

We create the S3 bucket and DynamoDB table first — manually, just this once — because Terraform cannot manage the backend that stores its own state.

1.1 Project Structure

Bash
mkdir platform-infrastructure && cd platform-infrastructure
git init
mkdir -p modules/vpc
mkdir -p modules/eks
mkdir -p modules/ecr
mkdir -p environments/production
mkdir -p environments/staging
echo "✅ Project structure created"
◈ DIAGRAM
platform-infrastructure/
modules/
vpc/ ← reusable VPC module
eks/ ← reusable EKS module
ecr/ ← reusable ECR module
environments/
production/ ← production environment config
staging/ ← staging environment config (uses same modules, different values)

This structure separates what (modules — the reusable logic) from where (environments — the specific configuration). The same VPC module can create a production VPC with large subnets and a staging VPC with smaller ones.

1.2 Create Remote State Infrastructure

Bash
## Set your AWS region
export AWS_REGION=ap-south-1 ## Mumbai — closest to India
## Create S3 bucket for Terraform state
## Bucket names must be globally unique — add your account ID
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export TF_STATE_BUCKET="terraform-state-${ACCOUNT_ID}-${AWS_REGION}"
aws s3api create-bucket \
--bucket ${TF_STATE_BUCKET} \
--region ${AWS_REGION} \
--create-bucket-configuration LocationConstraint=${AWS_REGION}
## Enable versioning — lets you recover previous state files
aws s3api put-bucket-versioning \
--bucket ${TF_STATE_BUCKET} \
--versioning-configuration Status=Enabled
## Enable encryption — state files contain sensitive data
aws s3api put-bucket-encryption \
--bucket ${TF_STATE_BUCKET} \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
}
}]
}'
## Block public access — state files must NEVER be public
aws s3api put-public-access-block \
--bucket ${TF_STATE_BUCKET} \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
## Create DynamoDB table for state locking
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region ${AWS_REGION}
echo "✅ Remote state infrastructure created"
echo "S3 Bucket: ${TF_STATE_BUCKET}"
echo "DynamoDB Table: terraform-state-lock"

Part 2 — The VPC Module

What Is a VPC and Why Do We Need One?

A VPC (Virtual Private Cloud) is your private network inside AWS. Think of it as renting a section of the AWS data centre that is completely isolated from other customers. Nothing gets in or out unless you explicitly allow it.

Without a VPC you would be using the AWS default network — which is shared, less configurable, and not suitable for production workloads.

Inside the VPC we create subnets:

Public subnets — directly connected to the internet via an Internet Gateway. Load balancers live here. Internet users can reach them.

Private subnets — NOT directly connected to the internet. EKS worker nodes live here. They can reach the internet through a NAT Gateway (for downloading packages, calling AWS APIs) but the internet cannot initiate connections to them.

This pattern — public subnets for load balancers, private subnets for compute — is the standard for secure production AWS architectures.

◈ DIAGRAM
Internet
|
Internet Gateway
|
Public Subnet (10.0.0.0/24, 10.0.1.0/24)
| ← Load Balancers live here
|
NAT Gateway ← Private subnets use this to reach the internet
|
Private Subnet (10.0.10.0/24, 10.0.11.0/24)
← EKS nodes live here (not reachable from internet)

2.1 VPC Module

HCL
# modules/vpc/main.tf
# ── Variables ────────────────────────────────────────────────
# Variables are like function parameters — they make the module reusable
variable "cluster_name" {
description = "Name of the EKS cluster — used for tagging"
type = string
}
variable "vpc_cidr" {
description = "IP address range for the VPC (e.g. 10.0.0.0/16)"
type = string
default = "10.0.0.0/16"
}
variable "availability_zones" {
description = "List of AZs to create subnets in — use 2+ for high availability"
type = list(string)
}
variable "public_subnet_cidrs" {
description = "IP ranges for public subnets — one per AZ"
type = list(string)
}
variable "private_subnet_cidrs" {
description = "IP ranges for private subnets — one per AZ"
type = list(string)
}
variable "environment" {
description = "Environment name (production, staging)"
type = string
}
# ── VPC ────────────────────────────────────────────────────
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
# enable_dns_hostnames lets EC2 instances get DNS names
# Required for EKS to work correctly
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.cluster_name}-vpc"
Environment = var.environment
# These tags are required for EKS to discover which VPC to use
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
# ── Internet Gateway ────────────────────────────────────────
# The Internet Gateway is what connects the VPC to the public internet
# Without it, nothing in the VPC can reach the internet
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.cluster_name}-igw"
Environment = var.environment
}
}
# ── Public Subnets ──────────────────────────────────────────
# count = length(var.availability_zones) creates one subnet per AZ
# If you pass 2 AZs, you get 2 public subnets
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
# Instances launched in public subnets automatically get a public IP
map_public_ip_on_launch = true
tags = {
Name = "${var.cluster_name}-public-${var.availability_zones[count.index]}"
Environment = var.environment
# This tag tells EKS this subnet is for external load balancers
"kubernetes.io/role/elb" = "1"
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
# ── Private Subnets ─────────────────────────────────────────
resource "aws_subnet" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
# Private subnets do NOT get public IPs
map_public_ip_on_launch = false
tags = {
Name = "${var.cluster_name}-private-${var.availability_zones[count.index]}"
Environment = var.environment
# This tag tells EKS this subnet is for internal load balancers and nodes
"kubernetes.io/role/internal-elb" = "1"
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
}
# ── Elastic IPs for NAT Gateways ─────────────────────────────
# A NAT Gateway needs a fixed public IP address
# Elastic IP gives us a static public IP we control
resource "aws_eip" "nat" {
count = length(var.availability_zones)
domain = "vpc"
tags = {
Name = "${var.cluster_name}-nat-eip-${count.index}"
Environment = var.environment
}
# Wait for the Internet Gateway to exist before creating EIPs
depends_on = [aws_internet_gateway.main]
}
# ── NAT Gateways ────────────────────────────────────────────
# NAT Gateway allows private subnet instances to reach the internet
# (for downloading packages, calling AWS APIs)
# without allowing the internet to initiate connections to them
# We create one per AZ for high availability
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 # NAT lives in public subnet
tags = {
Name = "${var.cluster_name}-nat-${var.availability_zones[count.index]}"
Environment = var.environment
}
}
# ── Route Tables ─────────────────────────────────────────────
# A route table is like a GPS — it tells traffic where to go
# Public route table: send internet-bound traffic to Internet Gateway
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0" # all traffic not in VPC
gateway_id = aws_internet_gateway.main.id # send to Internet Gateway
}
tags = {
Name = "${var.cluster_name}-public-rt"
Environment = var.environment
}
}
# Associate each public subnet with the public route table
resource "aws_route_table_association" "public" {
count = length(var.availability_zones)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# Private route tables: one per AZ, each pointing to its AZ's NAT Gateway
resource "aws_route_table" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[count.index].id
}
tags = {
Name = "${var.cluster_name}-private-rt-${var.availability_zones[count.index]}"
Environment = var.environment
}
}
resource "aws_route_table_association" "private" {
count = length(var.availability_zones)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
# ── Outputs ──────────────────────────────────────────────────
# Outputs expose values from this module so other modules can use them
output "vpc_id" {
description = "VPC ID — needed by EKS module"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "IDs of public subnets"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "IDs of private subnets — EKS nodes go here"
value = aws_subnet.private[*].id
}

Part 3 — The EKS Module

What Is EKS and What Does AWS Manage for You?

EKS (Elastic Kubernetes Service) is AWS's managed Kubernetes offering. Instead of installing and maintaining Kubernetes yourself, AWS runs the control plane (API server, etcd, scheduler, controller manager) for you.

What AWS manages:

  • Kubernetes API server (high availability, patching, upgrades)
  • etcd (the database where Kubernetes stores all state)
  • Control plane nodes

What you manage:

  • Worker nodes (the EC2 instances where your pods run)
  • Networking configuration
  • IAM permissions

This split means you get the power of Kubernetes without the operational burden of running its most complex components.

What Is IRSA and Why Is It So Important?

IRSA (IAM Roles for Service Accounts) is the correct way to give Kubernetes pods permission to access AWS services.

The wrong way (what many people do first):

◈ DIAGRAM
Put AWS access keys in environment variables
→ Keys are visible in pod description
→ Keys might be committed to Git accidentally
→ All pods on the node share the same permissions
→ Rotating keys requires restarting pods

The right way (IRSA):

◈ DIAGRAM
IAM Role attached to Kubernetes Service Account
→ No keys anywhere
→ Each pod gets exactly the permissions it needs
→ AWS issues temporary credentials automatically
→ Credentials rotate every hour automatically

IRSA works by linking an IAM role to a Kubernetes Service Account using OIDC (OpenID Connect) — a standard protocol for federated identity. AWS issues temporary credentials to pods that use the Service Account, without any keys being stored anywhere.

3.1 IAM Roles for EKS

HCL
# modules/eks/iam.tf
# ── EKS Cluster Role ─────────────────────────────────────────
# This role is what the EKS control plane uses to manage AWS resources
# on your behalf (creating load balancers, managing network interfaces, etc.)
data "aws_iam_policy_document" "eks_cluster_assume_role" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["eks.amazonaws.com"]
}
actions = ["sts:AssumeRole"]
}
}
resource "aws_iam_role" "eks_cluster" {
name = "${var.cluster_name}-cluster-role"
assume_role_policy = data.aws_iam_policy_document.eks_cluster_assume_role.json
tags = {
Name = "${var.cluster_name}-cluster-role"
Environment = var.environment
}
}
# Attach the AWS-managed policy that gives EKS the permissions it needs
resource "aws_iam_role_policy_attachment" "eks_cluster_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
role = aws_iam_role.eks_cluster.name
}
# ── EKS Node Group Role ───────────────────────────────────────
# This role is what EC2 worker nodes use
# Nodes need to register with the cluster, pull images from ECR, etc.
data "aws_iam_policy_document" "eks_node_assume_role" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
actions = ["sts:AssumeRole"]
}
}
resource "aws_iam_role" "eks_node" {
name = "${var.cluster_name}-node-role"
assume_role_policy = data.aws_iam_policy_document.eks_node_assume_role.json
tags = {
Name = "${var.cluster_name}-node-role"
Environment = var.environment
}
}
# Three policies are required for EKS worker nodes:
resource "aws_iam_role_policy_attachment" "eks_worker_node_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
role = aws_iam_role.eks_node.name
}
resource "aws_iam_role_policy_attachment" "eks_cni_policy" {
# CNI plugin manages pod networking — needs VPC permissions
policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
role = aws_iam_role.eks_node.name
}
resource "aws_iam_role_policy_attachment" "ecr_read_only" {
# Nodes need to pull Docker images from ECR
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
role = aws_iam_role.eks_node.name
}

3.2 EKS Cluster and Node Group

HCL
# modules/eks/main.tf
variable "cluster_name" { type = string }
variable "cluster_version" { type = string; default = "1.30" }
variable "vpc_id" { type = string }
variable "private_subnet_ids" { type = list(string) }
variable "public_subnet_ids" { type = list(string) }
variable "environment" { type = string }
variable "node_instance_types" {
type = list(string)
default = ["t3.medium"]
}
variable "node_desired_size" { type = number; default = 2 }
variable "node_min_size" { type = number; default = 1 }
variable "node_max_size" { type = number; default = 5 }
# ── EKS Cluster ───────────────────────────────────────────────
resource "aws_eks_cluster" "main" {
name = var.cluster_name
version = var.cluster_version
role_arn = aws_iam_role.eks_cluster.arn
vpc_config {
# Subnets where EKS can place networking resources
subnet_ids = concat(var.private_subnet_ids, var.public_subnet_ids)
# endpoint_private_access: kubectl works from inside the VPC
endpoint_private_access = true
# endpoint_public_access: kubectl works from your laptop (internet)
# Set to false in production and use a VPN or bastion host
endpoint_public_access = true
}
# Enable control plane logging to CloudWatch
# Useful for debugging authentication and API issues
enabled_cluster_log_types = ["api", "audit", "authenticator"]
tags = {
Name = var.cluster_name
Environment = var.environment
}
depends_on = [
aws_iam_role_policy_attachment.eks_cluster_policy
]
}
# ── OIDC Provider for IRSA ────────────────────────────────────
# This is what makes IRSA work
# It creates a trust relationship between EKS and AWS IAM
# so IAM can verify that a token came from a pod in this specific cluster
data "tls_certificate" "eks" {
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
}
resource "aws_iam_openid_connect_provider" "eks" {
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.eks.certificates[0].sha1_fingerprint]
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
tags = {
Name = "${var.cluster_name}-oidc"
Environment = var.environment
}
}
# ── Node Group ────────────────────────────────────────────────
# A node group is a group of EC2 instances that run your pods
# AWS manages the EC2 instances — creates them, patches them, replaces them
resource "aws_eks_node_group" "main" {
cluster_name = aws_eks_cluster.main.name
node_group_name = "${var.cluster_name}-nodes"
node_role_arn = aws_iam_role.eks_node.arn
# Nodes go in private subnets — not directly accessible from internet
subnet_ids = var.private_subnet_ids
# Allow multiple instance types — Karpenter can use this for Spot diversity
instance_types = var.node_instance_types
# Scaling configuration
scaling_config {
desired_size = var.node_desired_size
min_size = var.node_min_size
max_size = var.node_max_size
}
# Update strategy: replace nodes one by one (zero downtime)
update_config {
max_unavailable = 1
}
tags = {
Name = "${var.cluster_name}-node-group"
Environment = var.environment
}
depends_on = [
aws_iam_role_policy_attachment.eks_worker_node_policy,
aws_iam_role_policy_attachment.eks_cni_policy,
aws_iam_role_policy_attachment.ecr_read_only,
]
}
# ── Outputs ──────────────────────────────────────────────────
output "cluster_name" { value = aws_eks_cluster.main.name }
output "cluster_endpoint" { value = aws_eks_cluster.main.endpoint }
output "cluster_ca_certificate" { value = aws_eks_cluster.main.certificate_authority[0].data }
output "oidc_provider_arn" { value = aws_iam_openid_connect_provider.eks.arn }
output "oidc_provider_url" { value = aws_iam_openid_connect_provider.eks.url }

Part 4 — The ECR Module

What Is ECR and Why Not Use Docker Hub?

ECR (Elastic Container Registry) is AWS's private container registry. When Kubernetes pulls your Docker images, it uses ECR.

Why not Docker Hub?

  • Docker Hub rate-limits image pulls for free accounts (100 pulls per 6 hours)
  • ECR is inside AWS — image pulls are fast and free within the same region
  • ECR integrates with AWS IAM — your EKS nodes can pull images without passwords
  • ECR scans images for security vulnerabilities automatically
HCL
# modules/ecr/main.tf
variable "repositories" {
description = "List of repository names to create"
type = list(string)
}
variable "environment" { type = string }
resource "aws_ecr_repository" "main" {
for_each = toset(var.repositories)
name = each.value
image_tag_mutability = "MUTABLE" # allows overwriting tags like 'latest'
# Enable automatic vulnerability scanning when images are pushed
image_scanning_configuration {
scan_on_push = true
}
# Encrypt images at rest
encryption_configuration {
encryption_type = "AES256"
}
tags = {
Name = each.value
Environment = var.environment
}
}
# Lifecycle policy: automatically delete old images to save storage costs
# Keep the last 30 tagged images, delete untagged images after 1 day
resource "aws_ecr_lifecycle_policy" "main" {
for_each = toset(var.repositories)
repository = aws_ecr_repository.main[each.value].name
policy = jsonencode({
rules = [
{
rulePriority = 1
description = "Remove untagged images after 1 day"
selection = {
tagStatus = "untagged"
countType = "sinceImagePushed"
countUnit = "days"
countNumber = 1
}
action = { type = "expire" }
},
{
rulePriority = 2
description = "Keep only last 30 tagged images"
selection = {
tagStatus = "tagged"
tagPrefixList = ["v"]
countType = "imageCountMoreThan"
countNumber = 30
}
action = { type = "expire" }
}
]
})
}
output "repository_urls" {
description = "Map of repository name → URL"
value = { for k, v in aws_ecr_repository.main : k => v.repository_url }
}

Part 5 — Production Environment Configuration

Connecting Everything Together

Now we use the modules we built. The environment configuration calls each module with specific values for production.

HCL
# environments/production/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# Remote state backend
# Replace bucket name with the one you created in Part 1
backend "s3" {
bucket = "terraform-state-YOUR_ACCOUNT_ID-ap-south-1"
key = "production/terraform.tfstate"
region = "ap-south-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}
provider "aws" {
region = "ap-south-1"
default_tags {
tags = {
ManagedBy = "terraform"
Environment = "production"
Project = "platform-engineering"
}
}
}
# ── Local Variables ───────────────────────────────────────────
# Variables specific to this environment
locals {
cluster_name = "platform-production"
environment = "production"
region = "ap-south-1"
# Mumbai availability zones
availability_zones = ["ap-south-1a", "ap-south-1b"]
}
# ── VPC ───────────────────────────────────────────────────────
module "vpc" {
source = "../../modules/vpc"
cluster_name = local.cluster_name
environment = local.environment
vpc_cidr = "10.0.0.0/16"
availability_zones = local.availability_zones
# Public subnets: one per AZ, /24 gives 254 usable IPs each
public_subnet_cidrs = ["10.0.0.0/24", "10.0.1.0/24"]
# Private subnets: larger blocks for EKS pods (/20 = 4094 IPs each)
private_subnet_cidrs = ["10.0.10.0/20", "10.0.26.0/20"]
}
# ── EKS Cluster ───────────────────────────────────────────────
module "eks" {
source = "../../modules/eks"
cluster_name = local.cluster_name
cluster_version = "1.30"
environment = local.environment
vpc_id = module.vpc.vpc_id
private_subnet_ids = module.vpc.private_subnet_ids
public_subnet_ids = module.vpc.public_subnet_ids
# Diverse instance types for better Spot availability
node_instance_types = ["t3.medium", "t3a.medium", "t3.large"]
node_desired_size = 2
node_min_size = 1
node_max_size = 10
}
# ── ECR Repositories ──────────────────────────────────────────
module "ecr" {
source = "../../modules/ecr"
environment = local.environment
repositories = [
"swiggy-backend",
"swiggy-frontend",
]
}
# ── IRSA: Allow pods to push/pull from ECR ────────────────────
# This creates an IAM role that pods can assume via their Service Account
# instead of using hardcoded AWS credentials
data "aws_iam_policy_document" "ecr_access_assume_role" {
statement {
effect = "Allow"
principals {
type = "Federated"
identifiers = [module.eks.oidc_provider_arn]
}
actions = ["sts:AssumeRoleWithWebIdentity"]
condition {
test = "StringEquals"
variable = "${module.eks.oidc_provider_url}:sub"
# Only the 'backend' Service Account in 'swiggy-clone' namespace can assume this role
values = ["system:serviceaccount:swiggy-clone:backend-sa"]
}
}
}
resource "aws_iam_role" "ecr_access" {
name = "${local.cluster_name}-ecr-access"
assume_role_policy = data.aws_iam_policy_document.ecr_access_assume_role.json
}
resource "aws_iam_role_policy_attachment" "ecr_power_user" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser"
role = aws_iam_role.ecr_access.name
}
HCL
# environments/production/outputs.tf
output "cluster_name" {
description = "EKS cluster name — use with aws eks update-kubeconfig"
value = module.eks.cluster_name
}
output "cluster_endpoint" {
description = "Kubernetes API server endpoint"
value = module.eks.cluster_endpoint
}
output "ecr_repository_urls" {
description = "ECR repository URLs for pushing images"
value = module.ecr.repository_urls
}
output "vpc_id" {
value = module.vpc.vpc_id
}
output "private_subnet_ids" {
value = module.vpc.private_subnet_ids
}
output "irsa_role_arn" {
description = "ARN of the IRSA role — use in Kubernetes ServiceAccount annotation"
value = aws_iam_role.ecr_access.arn
}

Part 6 — Deploy the Infrastructure

The Terraform Workflow

Every Terraform deployment follows the same three steps:

  1. init — downloads the required providers and modules
  2. plan — shows what will be created/changed/destroyed without doing it
  3. apply — actually creates the infrastructure

Always review the plan before applying. The plan output is explicit — it shows every resource that will be created with a + sign, modified with ~, or destroyed with -.

Bash
cd environments/production
## Step 1: Initialise Terraform
## Downloads the AWS provider, modules, and configures the S3 backend
terraform init
## Expected output:
## Initializing the backend...
## Successfully configured the backend "s3"!
## Initializing provider plugins...
## - Installing hashicorp/aws v5.x.x...
## Terraform has been successfully initialized!
## Step 2: Format and validate the code
terraform fmt # auto-formats .tf files to standard style
terraform validate # checks syntax without contacting AWS
## Step 3: Plan — see what will be created
terraform plan -out=production.tfplan
## Read the output carefully!
## Look for: "Plan: X to add, Y to change, Z to destroy"
## For a fresh deployment, everything should be "to add"
## If anything shows "to destroy" that you did not expect — STOP and investigate
## Step 4: Apply — create the infrastructure
## This takes 10-15 minutes (EKS cluster creation is slow)
terraform apply production.tfplan
## You will see resources being created in real time:
## aws_vpc.main: Creating...
## aws_vpc.main: Creation complete after 2s
## aws_subnet.public[0]: Creating...
## ...
## aws_eks_cluster.main: Creating... (takes ~10 minutes)
## aws_eks_cluster.main: Creation complete after 10m32s
## Apply complete! Resources: 35 added, 0 changed, 0 destroyed.

Connect kubectl to Your New Cluster

Bash
## Get the cluster name from Terraform outputs
terraform output cluster_name
## Output: "platform-production"
## Update your kubeconfig to connect to the new cluster
aws eks update-kubeconfig \
--name platform-production \
--region ap-south-1
## Verify connection
kubectl get nodes
## NAME STATUS ROLES AGE
## ip-10-0-10-x.ec2.internal Ready <none> 3m
## ip-10-0-26-x.ec2.internal Ready <none> 3m
echo "✅ kubectl connected to your new EKS cluster"

Part 7 — Push Images to ECR and Deploy Capstone 1

Now that the infrastructure exists, deploy the application from Capstone 1 to this real cluster.

7.1 Push Docker Images to ECR

Bash
## Get ECR repository URLs from Terraform
BACKEND_REPO=$(terraform output -raw ecr_repository_urls | jq -r '.swiggy_backend')
FRONTEND_REPO=$(terraform output -raw ecr_repository_urls | jq -r '.swiggy_frontend')
echo "Backend repo: ${BACKEND_REPO}"
echo "Frontend repo: ${FRONTEND_REPO}"
## Authenticate Docker with ECR
## This gets a temporary login token (valid for 12 hours)
aws ecr get-login-password --region ap-south-1 | \
docker login --username AWS --password-stdin ${BACKEND_REPO}
## Build and push backend
cd ../../ ## back to swiggy-clone directory from Capstone 1
docker build -t ${BACKEND_REPO}:v1.0.0 ./backend
docker push ${BACKEND_REPO}:v1.0.0
## Build and push frontend
docker build -t ${FRONTEND_REPO}:v1.0.0 ./frontend
docker push ${FRONTEND_REPO}:v1.0.0
echo "✅ Images pushed to ECR"

7.2 Update Kubernetes Manifests

Bash
## Update the image references in your k8s manifests to use ECR URLs
## Replace 'your-registry/swiggy-backend' with your actual ECR URL
sed -i "s|your-registry/swiggy-backend:v1.0.0|${BACKEND_REPO}:v1.0.0|g" \
k8s/backend/deployment.yaml
sed -i "s|your-registry/swiggy-frontend:v1.0.0|${FRONTEND_REPO}:v1.0.0|g" \
k8s/frontend/deployment.yaml
## Deploy everything to the new cluster
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/database/
kubectl apply -f k8s/cache/
kubectl apply -f k8s/backend/
kubectl apply -f k8s/frontend/
kubectl apply -f k8s/ingress/
## Verify everything is running
kubectl get pods -n swiggy-clone

7.3 Create the IRSA Service Account

Bash
## Get the IRSA role ARN from Terraform
IRSA_ROLE_ARN=$(cd environments/production && terraform output -raw irsa_role_arn)
## Create a Kubernetes Service Account that links to the IAM role
## The annotation is what makes IRSA work
kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: backend-sa
namespace: swiggy-clone
annotations:
# This annotation tells AWS which IAM role this Service Account can assume
eks.amazonaws.com/role-arn: ${IRSA_ROLE_ARN}
EOF
## Update the backend Deployment to use this Service Account
kubectl patch deployment backend -n swiggy-clone \
-p '{"spec":{"template":{"spec":{"serviceAccountName":"backend-sa"}}}}'
echo "✅ IRSA configured — backend can now access AWS services without credentials"

Part 8 — Production Checklist
Bash
## ─── 1. VPC has public and private subnets ───────────────────
aws ec2 describe-subnets \
--filters "Name=tag:Environment,Values=production" \
--query 'Subnets[*].{ID:SubnetId,AZ:AvailabilityZone,Public:MapPublicIpOnLaunch,CIDR:CidrBlock}' \
--output table
## Should show both public (MapPublicIpOnLaunch=True) and private subnets
## ─── 2. EKS nodes are in private subnets ────────────────────
kubectl get nodes -o wide
## EXTERNAL-IP column should show <none>
## Nodes should NOT have public IPs
## ─── 3. Remote state is configured ──────────────────────────
cat .terraform/terraform.tfstate | jq '.backend.type'
## Should return "s3"
## ─── 4. Images are in ECR (not Docker Hub) ──────────────────
kubectl get deployment backend -n swiggy-clone \
-o jsonpath='{.spec.template.spec.containers[0].image}'
## Should show ECR URL (contains .ecr.aws)
## ─── 5. IRSA is configured (no hardcoded credentials) ────────
kubectl get pods -n swiggy-clone -o json | \
jq '.items[].spec.containers[].env[]? | select(.name | contains("AWS_SECRET"))'
## Should return nothing — no AWS credentials in environment variables
## ─── 6. Terraform state is encrypted ────────────────────────
aws s3api get-bucket-encryption \
--bucket terraform-state-${ACCOUNT_ID}-ap-south-1
## Should show AES256 encryption
## ─── 7. No IAM user credentials on nodes ────────────────────
## Nodes should use IAM roles, not user credentials
kubectl exec -n swiggy-clone deployment/backend -- \
wget -qO- http://169.254.169.254/latest/meta-data/iam/info 2>/dev/null | \
jq '.InstanceProfileArn'
## Should show the node IAM role ARN
echo ""
echo "✅ Production infrastructure checklist complete"

Part 9 — Real Terraform Work: State, Import, and Debugging

Why This Section Exists

Most Terraform tutorials end at terraform apply. Real Platform Engineering work starts there. State files get corrupted. Resources get created manually outside Terraform. Dependencies create circular errors. Knowing how to handle these situations is what separates a Platform Engineer from someone who just followed a tutorial.

This section intentionally breaks things so you learn how to fix them safely.

9.1 Understanding and Inspecting State

The state file is Terraform's memory. It maps every resource in your .tf files to a real AWS resource ID. Without it Terraform does not know what it created.

Bash
## List every resource Terraform is tracking
terraform state list
## Example output:
## module.vpc.aws_vpc.main
## module.vpc.aws_subnet.public[0]
## module.vpc.aws_subnet.public[1]
## module.vpc.aws_subnet.private[0]
## module.vpc.aws_subnet.private[1]
## module.eks.aws_eks_cluster.main
## module.eks.aws_iam_role.eks_cluster
## ...
## Inspect the full details of one resource
terraform state show module.eks.aws_eks_cluster.main
## Shows every attribute Terraform knows about your EKS cluster:
## endpoint, arn, kubernetes_network_config, version, etc.

9.2 Breaking and Recovering State

Scenario: Someone deleted the S3 state file accidentally.

Bash
## Simulate the problem — delete the state file
## (NEVER do this in production — this is for learning only in a test environment)
aws s3 rm s3://${TF_STATE_BUCKET}/production/terraform.tfstate
## Now try to run terraform plan
terraform plan
## Error: Failed to load state: ...
## Terraform cannot find the state file and does not know what exists
## Recovery option 1: Restore from S3 versioning
## S3 versioning keeps old versions — this is why we enabled it in Part 1
aws s3api list-object-versions \
--bucket ${TF_STATE_BUCKET} \
--prefix production/terraform.tfstate \
--query 'Versions[*].{VersionId:VersionId,LastModified:LastModified}'
## Restore the previous version
aws s3api copy-object \
--copy-source "${TF_STATE_BUCKET}/production/terraform.tfstate?versionId=YOUR_VERSION_ID" \
--bucket ${TF_STATE_BUCKET} \
--key production/terraform.tfstate
## Verify recovery
terraform plan
## Should now show "No changes" if infrastructure still exists

Scenario: Terraform state and actual AWS resources are out of sync.

This happens when someone creates or changes an AWS resource manually in the console.

Bash
## Check what Terraform thinks vs what AWS has
terraform plan
## If plan shows changes you did not make, refresh state first
## This pulls actual AWS state and updates the local state file
terraform refresh
## Run plan again after refresh
terraform plan
## If still showing unexpected changes, someone edited something in the console
## Decide: update Terraform to match AWS, or update AWS to match Terraform

9.3 Importing Existing Resources

Scenario: Someone created an S3 bucket manually in the AWS console. You now want Terraform to manage it.

Importing tells Terraform: "This real AWS resource already exists. Start tracking it."

Bash
## Step 1: Write the Terraform resource block first (without applying)
## Add this to your environments/production/main.tf:
## resource "aws_s3_bucket" "app_assets" {
## bucket = "swiggy-clone-assets-${var.account_id}"
## }
## Step 2: Import the existing bucket into state
## Syntax: terraform import <resource_address> <aws_resource_id>
terraform import aws_s3_bucket.app_assets swiggy-clone-assets-123456789012
## Output:
## aws_s3_bucket.app_assets: Importing from ID "swiggy-clone-assets-123456789012"...
## aws_s3_bucket.app_assets: Import prepared!
## aws_s3_bucket.app_assets: Refreshing state...
## Import successful!
## Step 3: Run plan to see if your Terraform config matches the actual resource
terraform plan
## If plan shows changes, your Terraform config differs from the actual bucket
## Update your Terraform config to match, then plan should show "No changes"

9.4 Debugging Dependency Issues

Scenario: Terraform shows Error: cycle detected or resources fail because they depend on each other.

Bash
## Example error:
## Error: Cycle: module.eks.aws_eks_cluster.main,
## module.vpc.aws_subnet.private[0]
## This means Terraform found a circular dependency:
## A depends on B, B depends on A, neither can be created first
## Debug step 1: Find the cycle
terraform graph | grep -i "cycle\|depends"
## terraform graph outputs DOT format — pipe to dot command to visualise if available
## Debug step 2: Use explicit depends_on to break the cycle
## Add to the resource that should be created SECOND:
## depends_on = [module.vpc.aws_subnet.private]
## Debug step 3: Target apply — create only specific resources
## Useful when you need to create dependencies manually before the full apply
terraform apply -target=module.vpc
## Creates only the VPC module resources
terraform apply -target=module.eks
## Now creates EKS (VPC exists, dependency satisfied)

9.5 State Manipulation — Moving and Removing Resources

Scenario: You reorganised your Terraform modules. A resource moved from one module to another. Terraform thinks the old one needs to be destroyed and a new one created — but you do not want to destroy and recreate your production EKS cluster.

Bash
## terraform state mv moves a resource in state without destroying it
## Old location: aws_eks_cluster.main
## New location: module.eks.aws_eks_cluster.main
terraform state mv \
aws_eks_cluster.main \
module.eks.aws_eks_cluster.main
## Verify the move
terraform state list | grep eks_cluster
## Should show: module.eks.aws_eks_cluster.main
## Now run plan — should show "No changes" (resource still exists, just renamed in state)
terraform plan

Scenario: A resource was deleted in AWS directly. Terraform keeps trying to reconcile it.

Bash
## Remove the resource from state so Terraform stops tracking it
## WARNING: this does NOT delete the resource — it just stops Terraform from managing it
terraform state rm module.ecr.aws_ecr_repository.main["swiggy-backend"]
## After removal, terraform plan will show the resource as "to add" again
## Either re-import it (if it still exists) or let Terraform recreate it

9.6 Targeted Destroys for Cost Control

In a real company you might want to destroy staging infrastructure at night to save costs and recreate it in the morning.

Bash
## Destroy only the EKS node group (stops the EC2 instances, saves cost)
## The cluster control plane and VPC remain
terraform destroy \
-target=module.eks.aws_eks_node_group.main \
-auto-approve
## Recreate it in the morning
terraform apply \
-target=module.eks.aws_eks_node_group.main
## Or destroy the entire staging environment
cd ../staging
terraform destroy -auto-approve
## Destroys ALL staging resources in the correct order
## Recreate the next morning
terraform apply -auto-approve
Tip

Always use -target carefully. When you target specific resources, Terraform may not check all dependencies. Test targeted applies in staging before using them in production.

Storing Terraform state locally 💥 Two engineers on the team both run terraform apply at the same time. Both read the same state file (which shows the current infrastructure). Both generate a plan. Both start applying. One creates resources the other does not know about. State becomes corrupted. Neither engineer can run Terraform safely until someone manually repairs the state file. In the worst case, resources get duplicated or deleted. ✅ Always use remote state (S3 + DynamoDB locking) from day one. The 10 minutes it takes to set up saves hours of pain.


Putting EKS nodes in public subnets 💥 A developer creates the EKS node group using public subnets for simplicity. Nodes get public IP addresses. A security scanner finds port 10250 (kubelet) open to the internet. An attacker exploits a kubelet vulnerability. They can exec into any pod on that node and access secrets, service account tokens, and internal services. ✅ EKS nodes always go in private subnets. Only load balancers (managed by Kubernetes Services of type LoadBalancer or Ingress) go in public subnets.


Hardcoded AWS credentials in pod environment variables 💥 A developer needs a pod to upload files to S3. They create an IAM user, generate access keys, and put them in a Kubernetes Secret. Six months later someone discovers the keys have AdministratorAccess because it was "easier at the time." The keys have never been rotated. If the pod is compromised, the attacker has full AWS account access. ✅ Use IRSA. The pod gets temporary credentials scoped to exactly what it needs. Credentials auto-rotate every hour. No keys to manage or accidentally expose.


Running terraform apply without reviewing the plan 💥 A developer makes a "small change" to the VPC module and runs terraform apply without reading the plan. The change triggers recreation of the EKS cluster (because some EKS settings are immutable and can only be changed by destroying and recreating). All pods are evicted. The production database is unreachable for 15 minutes. ✅ Always run terraform plan and read every line. Pay special attention to resources marked with -/+ (destroy and recreate) or - (destroy). Never apply a plan you have not fully understood.


One large Terraform configuration for everything 💥 A team puts their entire infrastructure — VPC, EKS, RDS, 20 microservices — in one Terraform workspace. Every terraform plan takes 10 minutes because it checks all 500 resources. A developer making a small change to an S3 bucket accidentally destroys the entire VPC because they missed a dependency. The blast radius of any mistake is the entire infrastructure. ✅ Separate infrastructure into layers: network (VPC), cluster (EKS), and application (per-service resources). Each layer has its own Terraform workspace. Changes to application infrastructure cannot affect the VPC.


Debugging Playbook

Terraform plan shows unexpected destroy:

Bash
## If terraform plan shows resources being destroyed that you did not intend:
## Step 1: Read the reason — Terraform always explains why
terraform plan 2>&1 | grep -A5 "must be replaced\|forces replacement"
## Common reasons:
## "forces replacement" next to a field that cannot be updated in place
## means the resource must be destroyed and recreated
## Examples: EKS cluster version, VPC CIDR block, subnet AZ
## Step 2: Check if the state matches reality
terraform refresh # updates state to match actual AWS resources
## Step 3: If state is corrupted, inspect it
terraform state list # shows all resources in state
terraform state show aws_eks_cluster.main # shows details of one resource

EKS nodes not joining the cluster:

Bash
## Check node group status
aws eks describe-nodegroup \
--cluster-name platform-production \
--nodegroup-name platform-production-nodes \
--query 'nodegroup.status'
## If status is DEGRADED:
aws eks describe-nodegroup \
--cluster-name platform-production \
--nodegroup-name platform-production-nodes \
--query 'nodegroup.health'
## Shows the exact reason nodes cannot join
## Common issues:
## "Ec2SubnetInvalidConfiguration" → subnets missing required tags
## "AccessDenied" → node IAM role missing required policies
## "AmiIdNotFound" → specified AMI not available in this region

Pod cannot access AWS services (IRSA not working):

Bash
## Check the Service Account has the annotation
kubectl get serviceaccount backend-sa -n swiggy-clone -o yaml | \
grep role-arn
## Check the pod is using the Service Account
kubectl get pod <pod-name> -n swiggy-clone -o yaml | \
grep serviceAccountName
## Test AWS credentials inside the pod
kubectl exec -n swiggy-clone deployment/backend -- \
wget -qO- http://169.254.169.254/latest/meta-data/iam/security-credentials/
## Check the OIDC provider exists
aws iam list-open-id-connect-providers
## Common issues:
## OIDC provider not created → re-run terraform apply
## Wrong namespace in trust policy → check the IRSA role's trust relationship
## Service Account annotation missing → apply the Service Account manifest

What You Have Built

You just provisioned real cloud infrastructure the way professional Platform Engineers do it at Indian product companies.

TYPESCRIPT
Production VPC with public and private subnets across 2 AZs
NAT Gateways providing internet access for private nodes
EKS cluster running in private subnets (secure by design)
Managed node group with multi-instance-type support
OIDC provider enabling IRSA (no hardcoded AWS credentials anywhere)
ECR repositories for Docker images with lifecycle policies
Remote state in S3 with DynamoDB locking (safe team collaboration)
Terraform modules that can create identical staging environments in minutes
Capstone 1's application running on infrastructure you provisioned

More importantly, you understand why every decision was made. Why nodes go in private subnets. Why IRSA replaces IAM user credentials. Why remote state uses DynamoDB locking. Why modules separate from environments.

This is real Platform Engineering. Not clicking through the AWS console, not using someone else's pre-built cluster — you built it from scratch, in code, reproducibly, securely.

Next: Capstone 3 — Build a GitOps Platform with ArgoCD. You will deploy everything you built in Capstones 1 and 2 through proper GitOps delivery — multi-environment promotion, App of Apps, and progressive delivery with canary deployments.

Videos & Guides

No external walkthroughs, video tutorials, or reference files attached to this blueprint yet.