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.
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.
TipFargate 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.
mkdir terraform-ecs-project && cd terraform-ecs-projecttouch provider.tf variables.tf terraform.tfvars vpc.tf security-groups.tf \ rds.tf alb.tf ecs.tf outputs.tfS3 bucket names are global, so use your own unique name:
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=Enabledprovider.tf:
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 MistakeCopying a bucket name from a tutorial. Someone else already owns it, and
terraform initfails 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:
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:
project_name = "orders-app"environment = "dev"container_image = "nginx:stable"container_port = 80desired_count = 2db_name = "ordersdb"db_username = "dbadmin"SecurityThis file holds no secrets, which is deliberate. Keep it that way: never put a password in a
.tfvarsfile, 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:
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 internetresource "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 MistakeForgetting 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
PENDINGand 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:
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.
TipThe 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:
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"}SecurityNever 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:
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 MistakeLeaving 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:
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 MistakeSkipping
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:
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}terraform initterraform validateterraform planterraform applyExpect around 10 minutes, mostly for RDS.
Tip
terraform state listshows every resource Terraform tracks. Use it to confirm nothing was created outside Terraform's control.
Validation & Testing
Run these checks after terraform apply finishes.
# 1. The service runs the number of tasks you asked foraws 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 balancercurl -I http://$(terraform output -raw alb_dns_name)# Expected: HTTP/1.1 200 OK # 3. The database is private and encryptedaws 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 filesaws 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:
terraform destroyFor 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.