Project: Three-Tier AWS App
The three-tier architecture—network, compute, and data—is the canonical shape of a production web application, and it is the perfect capstone for everything Terraform offers: variables, modules, for_each, dependency ordering, and outputs that wire one layer into the next. In this project you will compose a multi-AZ VPC, an autoscaling EC2 application tier behind an Application Load Balancer, and a private RDS PostgreSQL database—all from reusable modules. The result is a complete, deployable stack you can terraform apply into a fresh AWS account. Everything here runs identically on Terraform 1.5+ and OpenTofu.
Architecture and layout
Each tier is isolated for security: the load balancer is the only public-facing component, app instances live in private subnets, and the database lives in its own private subnets reachable only from the app tier. We split the configuration into three local modules so each layer is independently testable and reusable.
three-tier/
├── main.tf # root: wires modules together
├── variables.tf
├── outputs.tf
├── terraform.tfvars
└── modules/
├── network/ # VPC, subnets, gateways, routes
├── app/ # ALB, launch template, ASG, security groups
└── database/ # RDS subnet group, instance, security group
The network module
The network module produces a VPC with public subnets (for the ALB) and two sets of private subnets (one for app instances, one for the database) spread across Availability Zones. It exposes the IDs the other modules consume. The full VPC body mirrors the VPC recipe—here we focus on the outputs that form the contract.
# modules/network/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
output "public_subnet_ids" {
value = [for s in aws_subnet.public : s.id]
}
output "app_subnet_ids" {
value = [for s in aws_subnet.app : s.id]
}
output "db_subnet_ids" {
value = [for s in aws_subnet.db : s.id]
}
The application tier
The app module owns the public ingress and the compute fleet. Traffic flows: internet → ALB (public subnets) → target group → EC2 instances (private subnets). Security groups enforce least privilege—the ALB accepts :443/:80 from the world, and instances accept :8080 only from the ALB’s security group.
# modules/app/main.tf
resource "aws_security_group" "alb" {
name_prefix = "alb-"
vpc_id = var.vpc_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" "app" {
name_prefix = "app-"
vpc_id = var.vpc_id
ingress {
from_port = 8080
to_port = 8080
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_lb" "app" {
name = "three-tier-alb"
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = var.public_subnet_ids
}
resource "aws_lb_target_group" "app" {
name = "three-tier-tg"
port = 8080
protocol = "HTTP"
vpc_id = var.vpc_id
health_check {
path = "/healthz"
healthy_threshold = 2
unhealthy_threshold = 3
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.app.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
The fleet itself is a launch template plus an Auto Scaling Group that registers with the target group. User data renders the database connection string from module variables so instances boot ready to serve.
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_launch_template" "app" {
name_prefix = "app-"
image_id = data.aws_ami.al2023.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.app.id]
user_data = base64encode(templatefile("${path.module}/init.sh.tftpl", {
db_endpoint = var.db_endpoint
db_name = var.db_name
}))
}
resource "aws_autoscaling_group" "app" {
name = "three-tier-asg"
min_size = 2
max_size = 6
desired_capacity = 2
vpc_zone_identifier = var.app_subnet_ids
target_group_arns = [aws_lb_target_group.app.arn]
health_check_type = "ELB"
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
tag {
key = "Name"
value = "three-tier-app"
propagate_at_launch = true
}
}
The database tier
RDS lives in dedicated private subnets via a DB subnet group, and its security group permits PostgreSQL (:5432) only from the app security group. Storing the password directly in HCL is unacceptable—we pull it from a variable marked sensitive (and in production, from AWS Secrets Manager).
# modules/database/main.tf
resource "aws_db_subnet_group" "main" {
name = "three-tier-db"
subnet_ids = var.db_subnet_ids
}
resource "aws_security_group" "db" {
name_prefix = "db-"
vpc_id = var.vpc_id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [var.app_security_group_id]
}
}
resource "aws_db_instance" "main" {
identifier = "three-tier-db"
engine = "postgres"
engine_version = "16.3"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_encrypted = true
db_name = var.db_name
username = var.db_username
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
multi_az = true
skip_final_snapshot = true
}
output "endpoint" {
value = aws_db_instance.main.address
}
A circular dependency lurks here: the app tier needs the DB endpoint, and the DB needs the app’s security group ID. Break it by having the database module accept
app_security_group_idas an input and exposing the security group ID from the app module as an output—Terraform’s graph then orders the app SG before RDS, and RDS before app instances.
Wiring it together
The root module passes outputs from one module into the inputs of the next. The dependency graph is implicit: Terraform sees module.network.app_subnet_ids and orders network first, then database, then app.
# main.tf
module "network" {
source = "./modules/network"
vpc_cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
}
module "database" {
source = "./modules/database"
vpc_id = module.network.vpc_id
db_subnet_ids = module.network.db_subnet_ids
app_security_group_id = module.app.security_group_id
db_name = "appdb"
db_username = "appuser"
db_password = var.db_password
}
module "app" {
source = "./modules/app"
vpc_id = module.network.vpc_id
public_subnet_ids = module.network.public_subnet_ids
app_subnet_ids = module.network.app_subnet_ids
instance_type = "t3.small"
db_endpoint = module.database.endpoint
db_name = "appdb"
}
output "app_url" {
value = "http://${module.app.alb_dns_name}"
}
Module input reference
| Module | Key inputs | Key outputs |
|---|---|---|
network | vpc_cidr, azs | vpc_id, public_subnet_ids, app_subnet_ids, db_subnet_ids |
app | vpc_id, public_subnet_ids, app_subnet_ids, db_endpoint | alb_dns_name, security_group_id |
database | vpc_id, db_subnet_ids, app_security_group_id, db_password | endpoint |
Deploying the stack
Set the secret out of band, then initialize and apply.
export TF_VAR_db_password='change-me-via-secrets-manager'
terraform init
terraform apply
Output:
Plan: 34 to add, 0 to change, 0 to destroy.
module.network.aws_vpc.main: Creation complete after 2s [id=vpc-0a1b...]
module.app.aws_security_group.app: Creation complete after 1s [id=sg-0c2d...]
module.database.aws_db_instance.main: Still creating... [4m10s elapsed]
module.database.aws_db_instance.main: Creation complete after 5m22s [id=three-tier-db]
module.app.aws_autoscaling_group.app: Creation complete after 1m04s [id=three-tier-asg]
Apply complete! Resources: 34 added, 0 changed, 0 destroyed.
Outputs:
app_url = "http://three-tier-alb-1234567890.us-east-1.elb.amazonaws.com"
The RDS instance dominates the apply time; once it is ready, browse to app_url and the ALB routes you to a healthy instance.
Best Practices
- Compose one module per tier so each layer can be versioned, tested, and reused independently across projects.
- Reference resources by security group ID—not CIDR—between tiers so least-privilege rules track instance lifecycles automatically.
- Break the app/DB circular dependency by passing the app security group ID into the database module rather than referencing the ASG.
- Mark
db_passwordsensitiveand inject it viaTF_VAR_or Secrets Manager; never commit credentials to state-adjacent files. - Enable
multi_azandstorage_encryptedon RDS, and usehealth_check_type = "ELB"so the ASG replaces instances the load balancer marks unhealthy. - Expose only the ALB to the internet; keep app and database subnets private with egress through NAT.