Skip to main content

Three-Tier App on ECS Fargate with Terraform

Build a 3-tier AWS app with Terraform: VPC, ALB, ECS Fargate containers, and PostgreSQL RDS, each in its own isolated subnet tier.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project builds a three-tier AWS environment with Terraform: an Application Load Balancer in public subnets, ECS Fargate containers in private subnets, and a PostgreSQL RDS database in a separate private tier that only the containers can reach. Everything is defined in plain Terraform files in the ap-south-1 region, so one command creates the environment and one command removes it. The result is a small, reviewable repository that shows you can build a secure, reproducible network and run containers on it.

This is the container-based, introductory three-tier project. You write flat Terraform files, run containers on Fargate (no servers to patch), and deploy by hand. When you are comfortable, move on to AWS 3-Tier on EC2 with Terraform and CI/CD, which adds reusable modules, EC2 Auto Scaling, HTTPS, and a deployment pipeline.

What you build:

  • A VPC with public, private app, and private data subnets across two availability zones
  • An ALB that is the only internet-facing component
  • An ECS Fargate service of two tasks behind the ALB
  • A PostgreSQL RDS instance whose password AWS generates and stores in Secrets Manager
  • Remote Terraform state in S3

What you learn: the three-tier security pattern, security group chaining, Fargate task definitions, how ECS reads secrets at startup, and why remote state matters.

flowchart TD U[Internet] --> ALB[ALB
public subnets] ALB --> ECS[ECS Fargate tasks
private app subnets] ECS --> DB[(PostgreSQL RDS
private data subnets)] classDef edge fill:#F97316,color:#fff classDef app fill:#326CE5,color:#fff classDef data fill:#10B981,color:#fff class ALB edge class ECS app class DB data

The problem this solves

Clicking through the AWS console to build networking, load balancers, containers, and a database is slow and nobody can repeat it exactly. A payments company that needs a second identical environment for staging either repeats every click or accepts drift between environments. Terraform describes the end state in code, so staging is the same files with different variable values, and every change goes through a pull request.

The security pattern matters as much as the automation. The load balancer is the only thing exposed to the internet, containers accept traffic only from the load balancer, and the database accepts traffic only from the containers.

Tip

Fargate is the serverless launch type for ECS. You state the CPU, memory, and image, and AWS runs the container without you managing or patching any EC2 instance.

Milestone 1: Set Up the Project and Remote State

This milestone creates the files you fill in and an S3 bucket that holds Terraform state. State is how Terraform remembers what it created. If it lives only on your laptop and the laptop dies, the resources keep running and billing but nothing can manage them.

Bash
mkdir terraform-ecs-project && cd terraform-ecs-project
touch provider.tf variables.tf terraform.tfvars vpc.tf security-groups.tf \
rds.tf alb.tf ecs.tf outputs.tf

S3 bucket names are global, so use your own unique name:

Bash
BUCKET=yourname-tfstate-$(aws sts get-caller-identity --query Account --output text)
aws s3api create-bucket --bucket $BUCKET --region ap-south-1 \
--create-bucket-configuration LocationConstraint=ap-south-1
aws s3api put-bucket-versioning --bucket $BUCKET \
--versioning-configuration Status=Enabled

provider.tf:

HCL
terraform {
required_version = ">= 1.11.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
backend "s3" {
bucket = "yourname-tfstate-ACCOUNT_ID"
key = "ecs-project/terraform.tfstate"
region = "ap-south-1"
encrypt = true
use_lockfile = true
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "Terraform"
}
}
}

use_lockfile = true stops two people applying at once by writing a lock file next to the state in S3. It needs Terraform 1.10 or newer.

Common Mistake

Copying a bucket name from a tutorial. Someone else already owns it, and terraform init fails with an access error. Always generate a name from your account ID.

Milestone 2: Define Variables

This milestone makes the code reusable: the same files deploy dev, staging, or production by changing values only. Notice there is no password variable, because the database password is generated by AWS in Milestone 5.

variables.tf:

HCL
variable "aws_region" {
type = string
default = "ap-south-1"
}
variable "project_name" {
type = string
}
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
variable "container_image" {
type = string
}
variable "container_port" {
type = number
default = 80
}
variable "desired_count" {
type = number
default = 2
}
variable "db_name" {
type = string
default = "appdb"
}
variable "db_username" {
type = string
}

terraform.tfvars:

HCL
project_name = "orders-app"
environment = "dev"
container_image = "nginx:stable"
container_port = 80
desired_count = 2
db_name = "ordersdb"
db_username = "dbadmin"
Security

This file holds no secrets, which is deliberate. Keep it that way: never put a password in a .tfvars file, because it ends up in Git history and in Terraform state.

Milestone 3: Build the VPC and Three Subnet Tiers

This milestone creates the network. The public subnets host the ALB, the private app subnets host the containers, and the private data subnets host the database. Only the public tier has a route to the internet gateway.

For learning, one NAT Gateway serves both app subnets. Production usually runs one per availability zone so a zone failure does not cut off outbound traffic, at a higher hourly cost.

vpc.tf:

HCL
data "aws_availability_zones" "available" {
state = "available"
}
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" {
domain = "vpc"
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
depends_on = [aws_internet_gateway.main]
}
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" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
}
# Data tier: no default route, so no path to the internet
resource "aws_route_table" "private_data" {
vpc_id = aws_vpc.main.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.id
}
resource "aws_route_table_association" "private_data" {
count = 2
subnet_id = aws_subnet.private_data[count.index].id
route_table_id = aws_route_table.private_data.id
}
Common Mistake

Forgetting that Fargate tasks in a private subnet need the NAT Gateway to pull their image and reach AWS APIs. Without that route, tasks sit in PENDING and then fail with an image pull error.

Milestone 4: Define Security Groups

This milestone creates the rules that actually enforce the three tiers. Each group accepts traffic only from the tier directly above it, by referencing that group's ID instead of an IP range.

security-groups.tf:

HCL
resource "aws_security_group" "alb" {
name_prefix = "${var.project_name}-alb-"
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_prefix = "${var.project_name}-ecs-"
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_prefix = "${var.project_name}-rds-"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.ecs.id]
}
}

A CIDR range such as 10.0.0.0/8 admits everything in that range, including resources you add later. A security group reference admits only resources that carry that group.

Tip

The ALB listens on HTTP only in this project to keep the focus on the network. The EC2 project adds an HTTPS listener with an ACM certificate.

Milestone 5: Create the RDS Database

This milestone creates the data tier. Setting manage_master_user_password = true makes AWS generate the password and store it in Secrets Manager, so it never appears in your code, tfvars, or state.

rds.tf:

HCL
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnets"
subnet_ids = aws_subnet.private_data[*].id
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-db"
engine = "postgres"
engine_version = "16"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_type = "gp3"
storage_encrypted = true
db_name = var.db_name
username = var.db_username
manage_master_user_password = true
vpc_security_group_ids = [aws_security_group.rds.id]
db_subnet_group_name = aws_db_subnet_group.main.name
publicly_accessible = false
multi_az = var.environment == "prod"
backup_retention_period = 7
deletion_protection = var.environment == "prod"
skip_final_snapshot = var.environment != "prod"
}
Security

Never set publicly_accessible = true. Automated scanners find exposed databases within hours and try to brute-force them. The chain ALB to ECS to RDS is your real protection, so do not weaken it.

Milestone 6: Create the Load Balancer

This milestone creates the single public entry point. The ALB forwards requests to a target group, and Fargate tasks register into that group by IP address.

alb.tf:

HCL
resource "aws_lb" "main" {
name = "${var.project_name}-alb"
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 = "/"
matcher = "200"
interval = 30
timeout = 5
healthy_threshold = 2
unhealthy_threshold = 3
}
}
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

Leaving out target_type = "ip". Fargate tasks register by IP, not instance ID, so with the wrong type healthy tasks never join the target group and every request returns 502 Bad Gateway.

Milestone 7: Create the ECS Cluster, Task, and Service

This milestone runs your container. The task execution role lets ECS pull the image, write logs, and read the database secret, and the task definition injects that secret as an environment variable at startup.

ecs.tf:

HCL
resource "aws_ecs_cluster" "main" {
name = "${var.project_name}-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_iam_role" "task_execution" {
name = "${var.project_name}-task-execution"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "task_execution" {
role = aws_iam_role.task_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role_policy" "read_db_secret" {
name = "read-db-secret"
role = aws_iam_role.task_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "secretsmanager:GetSecretValue"
Resource = aws_db_instance.main.master_user_secret[0].secret_arn
}]
})
}
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.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_USER", value = var.db_username },
{ name = "DB_PORT", value = "5432" }
]
# ECS reads the password from Secrets Manager when the task starts
secrets = [{
name = "DB_PASSWORD"
valueFrom = "${aws_db_instance.main.master_user_secret[0].secret_arn}:password::"
}]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "ecs"
}
}
}])
}
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]
}

The stock nginx image ignores the database variables. They are there so you can swap in your own application image later without changing any infrastructure code.

Common Mistake

Skipping depends_on = [aws_lb_listener.http]. ECS can try to register tasks before the listener exists, and the service creation fails with a target group error.

Milestone 8: Define Outputs and Deploy

This milestone prints the values you need for testing and runs the deployment. terraform plan shows exactly what will be created before anything changes, so read it before you apply.

outputs.tf:

HCL
output "alb_dns_name" {
value = aws_lb.main.dns_name
}
output "ecs_cluster_name" {
value = aws_ecs_cluster.main.name
}
output "ecs_service_name" {
value = aws_ecs_service.app.name
}
output "rds_endpoint" {
value = aws_db_instance.main.address
sensitive = true
}
Bash
terraform init
terraform validate
terraform plan
terraform apply

Expect around 10 minutes, mostly for RDS.

Tip

terraform state list shows every resource Terraform tracks. Use it to confirm nothing was created outside Terraform's control.

Validation & Testing

Run these checks after terraform apply finishes.

Bash
# 1. The service runs the number of tasks you asked for
aws ecs describe-services \
--cluster $(terraform output -raw ecs_cluster_name) \
--services $(terraform output -raw ecs_service_name) \
--region ap-south-1 \
--query 'services[0].[runningCount,desiredCount]'
# Expected: both numbers match (for example [2, 2])
# 2. The app answers through the load balancer
curl -I http://$(terraform output -raw alb_dns_name)
# Expected: HTTP/1.1 200 OK
# 3. The database is private and encrypted
aws rds describe-db-instances \
--db-instance-identifier orders-app-db \
--region ap-south-1 \
--query 'DBInstances[0].[PubliclyAccessible,StorageEncrypted]'
# Expected: [false, true]
# 4. The password lives in Secrets Manager, not in your files
aws rds describe-db-instances \
--db-instance-identifier orders-app-db \
--region ap-south-1 \
--query 'DBInstances[0].MasterUserSecret.SecretArn'

The RDS secret is listed by ARN only here. Do not print its value into your terminal history.

When you finish, remove everything so it stops billing:

Bash
terraform destroy

For your portfolio, record in the README what you built, the commands above with their output, and one thing you would change for production, such as a NAT Gateway per zone or an HTTPS listener.

Found a mistake, an outdated step or a broken link?