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.
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 ```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 ``` ---
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. ---
### 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" ``` ``` 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" ``` ---
### 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 ```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 } ``` ---
### 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 pods ``` The 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 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 } ``` ---
### 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 } } ``` ---
In Capstone 1 you built and deployed a three-tier application to Kubernetes. But there was a gap — where did that Kubern...
You could click through the AWS Console and create everything manually. Many people do this when they are learning. The ...
Why Remote State? By default Terraform stores state in a local file called terraform.tfstate. This works for learning bu...
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 r...
What Is EKS and What Does AWS Manage for You? EKS (Elastic Kubernetes Service) is AWS's managed Kubernetes offering. Ins...
What Is ECR and Why Not Use Docker Hub? ECR (Elastic Container Registry) is AWS's private container registry. When Kuber...
Connecting Everything Together Now we use the modules we built. The environment configuration calls each module with spe...
The Terraform Workflow Every Terraform deployment follows the same three steps: init — downloads the required providers ...
Now that the infrastructure exists, deploy the application from Capstone 1 to this real cluster. 7.1 Push Docker Images ...
---...
Why This Section Exists Most Terraform tutorials end at terraform apply. Real Platform Engineering work starts there. St...
Terraform plan shows unexpected destroy: EKS nodes not joining the cluster: Pod cannot access AWS services (IRSA not wor...
You just provisioned real cloud infrastructure the way professional Platform Engineers do it at Indian product companies...
Aligns directly with DevOps, Site Reliability (SRE), and Platform Engineering job descriptions.