Build a Three-Tier Production App on ECS Fargate with Terraform

Provision a complete production-grade AWS environment with Terraform — VPC, ECS Fargate, ALB, and RDS across three isolated network tiers.

Related Concepts & TermsEnvironment

Domains & Technologies

Domains
ECSRDS
Technologies
TERRAFORMAWS

Blueprint Walkthrough

Architecture Overview

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.

◈ DIAGRAM
[ 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.

Problem Solved

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.

Step-by-Step Implementation Milestones

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
Bash
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
Validation & Testing

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.

Common Mistakes

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.