Every production application at large-scale companies runs on infrastructure shaped like this: a load balancer sits in a public subnet and receives traffic from the internet. Application containers run in private subnets with no direct internet access of their own. A database sits in a separate private subnet that only the application containers are allowed to reach. Building this by hand in the AWS Console takes an hour, and nobody can reliably reproduce it. Terraform describes this entire environment as code — it creates all of it with one command, and destroys it with one command. That matters because infrastructure-as-code lives in Git, gets reviewed in pull requests, and every change is tracked and reversible. By the end of this project you will have written Terraform that builds this complete, production-grade environment from a blank AWS account in the `ap-south-1` (Mumbai) region. ```text [ Internet ] | v [ Application Load Balancer ] <- Public Subnet (2 AZs) | v [ ECS Fargate Tasks ] <- Private App Subnet (no internet) | v [ RDS PostgreSQL ] <- Private Data Subnet (ECS only) ``` > 💡 **Tip:** This is called a "three-tier" architecture because each layer (presentation, application, data) sits in its own network boundary. Each tier can only be reached by the tier directly above it — the database can never be reached from the internet, not even by accident.
Manually clicking through the AWS Console to build networking, load balancers, containers, and databases is slow, error-prone, and impossible to repeat consistently. Imagine a payments platform like Razorpay needing a second identical environment for staging — you either repeat every click by hand or you get configuration drift between environments, small inconsistencies that cause bugs which only show up in production. Terraform solves this by making infrastructure reproducible and reviewable. You describe the desired end-state in `.tf` files; Terraform figures out what needs to be created, changed, or destroyed to reach that state. The same code can spin up a perfect clone of production for staging by changing only variable values, and every infrastructure change can be reviewed in a pull request before it ever touches a real environment. This project also demonstrates the standard three-tier security pattern used in real production systems: the load balancer is the only thing exposed to the internet, application containers are only reachable through the load balancer, and the database is only reachable from the application containers — never from anywhere else. > 📌 **Remember:** "Infrastructure as Code" does not just mean automation — it means your infrastructure changes go through the same review, version control, and rollback process as your application code.
### Milestone 1: Understand the Core Concepts Before You Build Before writing any Terraform, it helps to understand what each piece actually does — not just the commands to run. **Terraform** is Infrastructure as Code (IaC). Instead of clicking through the AWS Console, you describe what you want in `.tf` files. Terraform reads those files, compares them to what currently exists in AWS, and makes only the changes needed to match. **ECS (Elastic Container Service)** runs Docker containers on AWS. **Fargate** is the serverless launch type for ECS — you specify what your container needs (CPU, memory, image), and AWS manages the underlying servers for you. You never see or patch the EC2 instances running your containers. **Three-tier architecture** means three separate network layers, each with its own security boundary: * Tier 1 — Presentation: the load balancer, in public subnets * Tier 2 — Application: containers, in private subnets * Tier 3 — Data: the database, in private subnets reachable only from Tier 2 Understanding *why* this separation exists is more important than memorizing the Terraform syntax — the syntax changes between versions, but the security reasoning does not. ### Milestone 2: Set Up the Project Structure Create the folder and files you will fill in throughout this project. ```bash mkdir terraform-ecs-project cd terraform-ecs-project touch provider.tf variables.tf vpc.tf \ security-groups.tf alb.tf \ ecs-cluster.tf ecs-task.tf \ ecs-service.tf rds.tf \ outputs.tf terraform.tfvars ``` ```text terraform-ecs-project/ provider.tf (AWS provider and backend config) variables.tf (all configurable values) terraform.tfvars (actual values for the variables) vpc.tf (VPC, subnets, IGW, NAT Gateway) security-groups.tf (firewall rules between tiers) alb.tf (load balancer, target group, listener) ecs-cluster.tf (the ECS cluster) ecs-task.tf (container definition) ecs-service.tf (the running service) rds.tf (PostgreSQL database) outputs.tf (values printed after apply) ``` ### Milestone 3: Configure the Provider and Remote State Remote state stores Terraform's state file in S3 instead of only on your laptop. State is how Terraform tracks what it has already created. If state only lives on your laptop and your laptop dies, Terraform loses track of everything — the resources still exist in AWS, but nothing can manage them anymore. `provider.tf`: ```hcl terraform { required_version = ">= 1.5.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } backend "s3" { bucket = "swiggy-terraform-state-bucket" key = "ecs-project/terraform.tfstate" region = "ap-south-1" dynamodb_table = "terraform-state-lock" encrypt = true } } provider "aws" { region = var.aws_region default_tags { tags = { Project = var.project_name Environment = var.environment ManagedBy = "Terraform" } } } ``` Create the S3 bucket and DynamoDB lock table before your first `terraform apply`: ```bash aws s3 mb s3://swiggy-terraform-state-bucket \ --region ap-south-1 aws s3api put-bucket-versioning \ --bucket swiggy-terraform-state-bucket \ --versioning-configuration Status=Enabled 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 ap-south-1 ``` > 📌 **Remember:** The DynamoDB table prevents two people from running `terraform apply` at the same time, which could corrupt your state file. ### Milestone 4: Define Variables Variables let the same Terraform code deploy dev, staging, or production by changing only values, never logic. `variables.tf`: ```hcl variable "aws_region" { description = "AWS region to deploy resources" type = string default = "ap-south-1" } variable "project_name" { description = "Name prefix for all resources" type = string } variable "environment" { description = "dev, staging, or prod" type = string validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "Environment must be dev, staging, or prod." } } variable "vpc_cidr" { description = "CIDR block for the VPC" type = string default = "10.0.0.0/16" } variable "container_image" { description = "Docker image URI for the app container" type = string } variable "container_port" { description = "Port the container listens on" type = number default = 80 } variable "desired_count" { description = "Number of ECS tasks to run" type = number default = 2 } variable "db_name" { description = "PostgreSQL database name" type = string default = "appdb" } variable "db_username" { description = "PostgreSQL master username" type = string sensitive = true } variable "db_password" { description = "PostgreSQL master password" type = string sensitive = true } ``` `terraform.tfvars`: ```hcl project_name = "swiggy-orders-app" environment = "dev" vpc_cidr = "10.0.0.0/16" container_image = "nginx:latest" container_port = 80 desired_count = 2 db_name = "ordersdb" db_username = "dbadmin" db_password = "ChangeMe2026!" ``` > ⚠️ **Security:** Never commit `terraform.tfvars` to Git if it contains passwords. Add it to `.gitignore` immediately, and use AWS Secrets Manager for real credentials. ### Milestone 5: Build the VPC and Three Subnet Tiers The VPC is the private network containing every resource. Public subnets host the load balancer. Private app subnets host ECS tasks. Private data subnets host RDS. `vpc.tf`: ```hcl resource "aws_vpc" "main" { cidr_block = var.vpc_cidr enable_dns_hostnames = true enable_dns_support = true } resource "aws_subnet" "public" { count = 2 vpc_id = aws_vpc.main.id cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index) availability_zone = data.aws_availability_zones.available.names[count.index] map_public_ip_on_launch = true } resource "aws_subnet" "private_app" { count = 2 vpc_id = aws_vpc.main.id cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10) availability_zone = data.aws_availability_zones.available.names[count.index] } resource "aws_subnet" "private_data" { count = 2 vpc_id = aws_vpc.main.id cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 20) availability_zone = data.aws_availability_zones.available.names[count.index] } resource "aws_internet_gateway" "main" { vpc_id = aws_vpc.main.id } resource "aws_eip" "nat" { count = 2 domain = "vpc" } resource "aws_nat_gateway" "main" { count = 2 allocation_id = aws_eip.nat[count.index].id subnet_id = aws_subnet.public[count.index].id } resource "aws_route_table" "public" { vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.main.id } } resource "aws_route_table" "private_app" { count = 2 vpc_id = aws_vpc.main.id route { cidr_block = "0.0.0.0/0" nat_gateway_id = aws_nat_gateway.main[count.index].id } } resource "aws_route_table_association" "public" { count = 2 subnet_id = aws_subnet.public[count.index].id route_table_id = aws_route_table.public.id } resource "aws_route_table_association" "private_app" { count = 2 subnet_id = aws_subnet.private_app[count.index].id route_table_id = aws_route_table.private_app[count.index].id } data "aws_availability_zones" "available" { state = "available" } ``` ### Milestone 6: Define Security Groups (The Real Protection) Security groups are what actually enforce the three-tier boundary. Each one only allows traffic from the tier directly above it. `security-groups.tf`: ```hcl resource "aws_security_group" "alb" { name = "${var.project_name}-alb-sg" vpc_id = aws_vpc.main.id ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } resource "aws_security_group" "ecs" { name = "${var.project_name}-ecs-sg" vpc_id = aws_vpc.main.id ingress { from_port = var.container_port to_port = var.container_port protocol = "tcp" security_groups = [aws_security_group.alb.id] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } resource "aws_security_group" "rds" { name = "${var.project_name}-rds-sg" vpc_id = aws_vpc.main.id ingress { from_port = 5432 to_port = 5432 protocol = "tcp" security_groups = [aws_security_group.ecs.id] } } ``` > 📌 **Remember:** Using Security Group IDs as sources (instead of CIDR ranges) is the correct production pattern. A CIDR range like `10.0.0.0/8` grants access to anything in that range, including future resources you never intended to allow. Referencing an SG ID means only resources explicitly assigned that SG can connect. ### Milestone 7: Create the Load Balancer `alb.tf`: ```hcl resource "aws_lb" "main" { name = "${var.project_name}-alb" internal = false load_balancer_type = "application" security_groups = [aws_security_group.alb.id] subnets = aws_subnet.public[*].id } resource "aws_lb_target_group" "app" { name = "${var.project_name}-tg" port = var.container_port protocol = "HTTP" vpc_id = aws_vpc.main.id target_type = "ip" health_check { path = "/" healthy_threshold = 2 unhealthy_threshold = 3 timeout = 5 interval = 30 matcher = "200" } } resource "aws_lb_listener" "http" { load_balancer_arn = aws_lb.main.arn port = 80 protocol = "HTTP" default_action { type = "forward" target_group_arn = aws_lb_target_group.app.arn } } ``` > 🔴 **Common Mistake:** Forgetting `target_type = "ip"` on the target group. EC2-launched containers register by instance ID; Fargate tasks register by IP address. Using the wrong type means healthy tasks never register with the load balancer, and every request returns 502 Bad Gateway. ### Milestone 8: Create the ECS Cluster, Task, and Service `ecs-cluster.tf`: ```hcl resource "aws_ecs_cluster" "main" { name = "${var.project_name}-cluster" setting { name = "containerInsights" value = "enabled" } } ``` `ecs-task.tf`: ```hcl resource "aws_iam_role" "ecs_task_execution" { name = "${var.project_name}-task-execution-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "ecs-tasks.amazonaws.com" } }] }) } resource "aws_iam_role_policy_attachment" "ecs_task_execution" { role = aws_iam_role.ecs_task_execution.name policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" } resource "aws_cloudwatch_log_group" "app" { name = "/ecs/${var.project_name}" retention_in_days = 30 } resource "aws_ecs_task_definition" "app" { family = "${var.project_name}-task" network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] cpu = 256 memory = 512 execution_role_arn = aws_iam_role.ecs_task_execution.arn container_definitions = jsonencode([{ name = var.project_name image = var.container_image portMappings = [{ containerPort = var.container_port protocol = "tcp" }] environment = [ { name = "DB_HOST", value = aws_db_instance.main.address }, { name = "DB_NAME", value = var.db_name }, { name = "DB_PORT", value = "5432" } ] logConfiguration = { logDriver = "awslogs" options = { "awslogs-group" = aws_cloudwatch_log_group.app.name "awslogs-region" = var.aws_region "awslogs-stream-prefix" = "ecs" } } }]) } ``` `ecs-service.tf`: ```hcl resource "aws_ecs_service" "app" { name = "${var.project_name}-service" cluster = aws_ecs_cluster.main.id task_definition = aws_ecs_task_definition.app.arn desired_count = var.desired_count launch_type = "FARGATE" network_configuration { subnets = aws_subnet.private_app[*].id security_groups = [aws_security_group.ecs.id] assign_public_ip = false } load_balancer { target_group_arn = aws_lb_target_group.app.arn container_name = var.project_name container_port = var.container_port } depends_on = [aws_lb_listener.http] } ``` > 🔴 **Common Mistake:** Skipping `depends_on = [aws_lb_listener.http]` on the service can cause ECS to try registering tasks with a target group that has no listener attached yet, since Terraform does not always infer this ordering automatically. ### Milestone 9: Create the RDS Database `rds.tf`: ```hcl resource "aws_db_subnet_group" "main" { name = "${var.project_name}-db-subnet-group" subnet_ids = aws_subnet.private_data[*].id } resource "aws_db_instance" "main" { identifier = "${var.project_name}-db" engine = "postgres" engine_version = "15.4" instance_class = "db.t3.micro" allocated_storage = 20 storage_type = "gp3" db_name = var.db_name username = var.db_username password = var.db_password vpc_security_group_ids = [aws_security_group.rds.id] db_subnet_group_name = aws_db_subnet_group.main.name multi_az = var.environment == "prod" ? true : false backup_retention_period = 7 deletion_protection = var.environment == "prod" ? true : false skip_final_snapshot = true publicly_accessible = false } ``` > ⚠️ **Security:** `publicly_accessible = false` must never be changed to `true`. Exposing RDS to the internet lets automated scanners find it and attempt to brute-force the password. The security group chain (ALB → ECS → RDS) is your only real protection — do not weaken it. ### Milestone 10: Define Outputs and Deploy `outputs.tf`: ```hcl output "alb_dns_name" { description = "DNS name of the load balancer" value = aws_lb.main.dns_name } output "rds_endpoint" { description = "RDS connection endpoint" value = aws_db_instance.main.address sensitive = true } output "ecs_cluster_name" { description = "ECS cluster name" value = aws_ecs_cluster.main.name } ``` Deploy everything: ```bash terraform init terraform validate terraform plan terraform apply ```
Run these checks after `terraform apply` completes to confirm your environment works end to end. ```bash # 1. Confirm ECS service is running the desired number of tasks aws ecs describe-services \ --cluster $(terraform output -raw ecs_cluster_name) \ --services swiggy-orders-app-service \ --region ap-south-1 \ --query 'services[0].[runningCount,desiredCount]' # Expected: runningCount == desiredCount # 2. Fetch the ALB DNS name and confirm the app responds ALB_URL=$(terraform output -raw alb_dns_name) curl -I http://$ALB_URL # Expected: HTTP/1.1 200 OK # 3. Confirm ECS tasks are listed and healthy aws ecs list-tasks \ --cluster $(terraform output -raw ecs_cluster_name) \ --region ap-south-1 ``` When you're done experimenting, tear everything down to avoid ongoing AWS charges: ```bash terraform destroy ``` > 💡 **Tip:** Run `terraform state list` at any point to see every resource Terraform is currently tracking — useful for confirming nothing was created outside of Terraform's control.
Skipping remote state setup before the first `terraform apply` is the most disruptive mistake a beginner can make. If state only exists on your laptop and the laptop is lost, Terraform can no longer track what it created — the resources keep running in AWS and costing money, but nothing can manage or destroy them cleanly. Setting `publicly_accessible = true` on RDS is a common but serious error. It exposes the database directly to the internet, bypassing the entire security group chain this project is built around. Hardcoding database passwords in `terraform.tfvars` and committing that file to a public GitHub repository has caused real security incidents. Always add `terraform.tfvars` to `.gitignore`, and use AWS Secrets Manager for production credentials. Forgetting `target_type = "ip"` on the ALB target group is the single most common ECS + ALB configuration error — it silently breaks health checks and causes every request to fail with 502 Bad Gateway. Deploying resources outside `ap-south-1` when the rest of your team's infrastructure, latency budget, and compliance requirements assume the Mumbai region causes cross-region data transfer costs and added latency for Indian end users — always pin `aws_region` explicitly rather than relying on a provider default.
Every production application at large-scale companies runs on infrastructure shaped like this: a load balancer sits in a...
Manually clicking through the AWS Console to build networking, load balancers, containers, and databases is slow, error-...
Milestone 1: Understand the Core Concepts Before You Build Before writing any Terraform, it helps to understand what eac...
Run these checks after terraform apply completes to confirm your environment works end to end. When you're done experime...
Skipping remote state setup before the first terraform apply is the most disruptive mistake a beginner can make. If stat...
Aligns directly with DevOps, Site Reliability (SRE), and Platform Engineering job descriptions.