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.
Domains & Technologies
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:
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
## Verify tools are installedaws --versionterraform --versionkubectl version --client ## Verify AWS credentials are configuredaws sts get-caller-identity## Should show your AWS Account ID, User ID, and ARN## If this fails, run: aws configureWhat 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.
Plan — terraform plan shows you what Terraform will do before it does it. Always run plan before apply.
Apply — terraform 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
mkdir platform-infrastructure && cd platform-infrastructuregit init mkdir -p modules/vpcmkdir -p modules/eksmkdir -p modules/ecrmkdir -p environments/productionmkdir -p environments/staging echo "✅ Project structure created"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
## Set your AWS regionexport AWS_REGION=ap-south-1 ## Mumbai — closest to India ## Create S3 bucket for Terraform state## Bucket names must be globally unique — add your account IDexport 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 filesaws s3api put-bucket-versioning \ --bucket ${TF_STATE_BUCKET} \ --versioning-configuration Status=Enabled ## Enable encryption — state files contain sensitive dataaws s3api put-bucket-encryption \ --bucket ${TF_STATE_BUCKET} \ --server-side-encryption-configuration '{ "Rules": [{ "ApplyServerSideEncryptionByDefault": { "SSEAlgorithm": "AES256" } }] }' ## Block public access — state files must NEVER be publicaws 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 lockingaws 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.
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
# modules/vpc/main.tf # ── Variables ────────────────────────────────────────────────# Variables are like function parameters — they make the module reusablevariable "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 internetresource "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 subnetsresource "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 controlresource "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 availabilityresource "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 Gatewayresource "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 tableresource "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 Gatewayresource "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 themoutput "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):
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 podsThe right way (IRSA):
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 automaticallyIRSA 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
# 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 needsresource "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
# 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
# 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 dayresource "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.
# 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 environmentlocals { 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}# 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:
- init — downloads the required providers and modules
- plan — shows what will be created/changed/destroyed without doing it
- 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 -.
cd environments/production ## Step 1: Initialise Terraform## Downloads the AWS provider, modules, and configures the S3 backendterraform 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 codeterraform fmt # auto-formats .tf files to standard styleterraform validate # checks syntax without contacting AWS ## Step 3: Plan — see what will be createdterraform 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
## Get the cluster name from Terraform outputsterraform output cluster_name## Output: "platform-production" ## Update your kubeconfig to connect to the new clusteraws eks update-kubeconfig \ --name platform-production \ --region ap-south-1 ## Verify connectionkubectl 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
## Get ECR repository URLs from TerraformBACKEND_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 backendcd ../../ ## back to swiggy-clone directory from Capstone 1docker build -t ${BACKEND_REPO}:v1.0.0 ./backenddocker push ${BACKEND_REPO}:v1.0.0 ## Build and push frontenddocker build -t ${FRONTEND_REPO}:v1.0.0 ./frontenddocker push ${FRONTEND_REPO}:v1.0.0 echo "✅ Images pushed to ECR"7.2 Update Kubernetes Manifests
## 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 clusterkubectl apply -f k8s/namespace.yamlkubectl 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 runningkubectl get pods -n swiggy-clone7.3 Create the IRSA Service Account
## Get the IRSA role ARN from TerraformIRSA_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 workkubectl apply -f - <<EOFapiVersion: v1kind: ServiceAccountmetadata: 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 Accountkubectl 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
## ─── 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 credentialskubectl 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.
## List every resource Terraform is trackingterraform 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 resourceterraform 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.
## 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 planterraform 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 1aws s3api list-object-versions \ --bucket ${TF_STATE_BUCKET} \ --prefix production/terraform.tfstate \ --query 'Versions[*].{VersionId:VersionId,LastModified:LastModified}' ## Restore the previous versionaws s3api copy-object \ --copy-source "${TF_STATE_BUCKET}/production/terraform.tfstate?versionId=YOUR_VERSION_ID" \ --bucket ${TF_STATE_BUCKET} \ --key production/terraform.tfstate ## Verify recoveryterraform plan## Should now show "No changes" if infrastructure still existsScenario: Terraform state and actual AWS resources are out of sync.
This happens when someone creates or changes an AWS resource manually in the console.
## Check what Terraform thinks vs what AWS hasterraform plan ## If plan shows changes you did not make, refresh state first## This pulls actual AWS state and updates the local state fileterraform refresh ## Run plan again after refreshterraform plan## If still showing unexpected changes, someone edited something in the console## Decide: update Terraform to match AWS, or update AWS to match Terraform9.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."
## 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 resourceterraform 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.
## 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 cycleterraform 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 applyterraform apply -target=module.vpc## Creates only the VPC module resourcesterraform 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.
## 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 moveterraform 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 planScenario: A resource was deleted in AWS directly. Terraform keeps trying to reconcile it.
## Remove the resource from state so Terraform stops tracking it## WARNING: this does NOT delete the resource — it just stops Terraform from managing itterraform 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 it9.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.
## Destroy only the EKS node group (stops the EC2 instances, saves cost)## The cluster control plane and VPC remainterraform destroy \ -target=module.eks.aws_eks_node_group.main \ -auto-approve ## Recreate it in the morningterraform apply \ -target=module.eks.aws_eks_node_group.main ## Or destroy the entire staging environmentcd ../stagingterraform destroy -auto-approve## Destroys ALL staging resources in the correct order ## Recreate the next morningterraform apply -auto-approveTipAlways use
-targetcarefully. 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:
## If terraform plan shows resources being destroyed that you did not intend: ## Step 1: Read the reason — Terraform always explains whyterraform 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 realityterraform refresh # updates state to match actual AWS resources ## Step 3: If state is corrupted, inspect itterraform state list # shows all resources in stateterraform state show aws_eks_cluster.main # shows details of one resourceEKS nodes not joining the cluster:
## Check node group statusaws 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 regionPod cannot access AWS services (IRSA not working):
## Check the Service Account has the annotationkubectl get serviceaccount backend-sa -n swiggy-clone -o yaml | \ grep role-arn ## Check the pod is using the Service Accountkubectl get pod <pod-name> -n swiggy-clone -o yaml | \ grep serviceAccountName ## Test AWS credentials inside the podkubectl exec -n swiggy-clone deployment/backend -- \ wget -qO- http://169.254.169.254/latest/meta-data/iam/security-credentials/ ## Check the OIDC provider existsaws 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 manifestWhat You Have Built
You just provisioned real cloud infrastructure the way professional Platform Engineers do it at Indian product companies.
✅ 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 provisionedMore 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.