Skip to content
Infrastructure as Code projects 6 min read

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_id as 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

ModuleKey inputsKey outputs
networkvpc_cidr, azsvpc_id, public_subnet_ids, app_subnet_ids, db_subnet_ids
appvpc_id, public_subnet_ids, app_subnet_ids, db_endpointalb_dns_name, security_group_id
databasevpc_id, db_subnet_ids, app_security_group_id, db_passwordendpoint

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_password sensitive and inject it via TF_VAR_ or Secrets Manager; never commit credentials to state-adjacent files.
  • Enable multi_az and storage_encrypted on RDS, and use health_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.
Last updated June 14, 2026
Was this helpful?