Everything in this path, assembled once. A three-tier architecture is the standard shape for a web application and exercises nearly every concept you have learned: modules, outputs wiring modules together, count over availability zones, security group chaining, instance profiles, remote state and locking.
Topic 1: The Architecture
| Tier | Placement | Components |
|---|---|---|
| Web | Public subnets | Auto-scaling group, launch template, load balancer |
| Application | Private subnets | Auto-scaling group, launch template, instance profile |
| Database | Private subnets | Managed relational instance, dedicated subnet group |
| Access | Public subnet | Bastion host, SSH restricted to a named source range |
| State | — | Object storage bucket + lock table |
| Artifacts | — | Object storage bucket, public access blocked |
┌──────────┐
│ Internet │
└────┬─────┘
▼
┌───────────────────┐
│ Internet Gateway │◀────────────┐
└─────┬───────┬─────┘ │
│ │ │
▼ ▼ │
┌────────────────┐ ┌──────────────┐ │
│ Load Balancer │ │ Bastion │ │
└───────┬────────┘ │ public subnet│ │
│ └──────┬───────┘ │
▼ ┆ SSH │
┌──────────────────────────┐ ┆ │
│ WEB TIER — ASG │◀────┘ │
│ public subnets, 2 AZs │──────┐ │
└───────────┬──────────────┘ │ │
▼ ▼ │
┌──────────────────────────┐ ┌──────────────┐ │
│ APP TIER — ASG │ │ Artifact │ │
│ private subnets, 2 AZs │ │ Bucket │ │
└───────┬──────────┬───────┘ └──────────────┘ │
│ │ │
▼ └──▶ ┌─────────────┐ │
┌──────────────────┐ │ NAT Gateway │────────┘
│ DATABASE │ └─────────────┘
│ private subnets │
└──────────────────┘
Topic 2: Layout
.
├── main.tf # backend + module composition
├── variables.tf
├── outputs.tf
├── providers.tf
├── versions.tf
├── terraform.tfvars
├── install_web.sh
├── install_app.sh
└── modules/
├── networking/ # VPC, subnets, IGW, NAT, route tables, SGs, DB subnet group
├── compute/ # launch templates, ASGs, key pair
├── loadbalancer/ # LB, target group, listener
├── database/ # managed database instance
└── artifacts/ # bucket, IAM policy, role, instance profile
Five modules, each with a single responsibility. The root module composes them and wires outputs to inputs — it should contain almost no resources of its own.
Topic 3: Root Composition
terraform {
backend "s3" {
bucket = "org-tf-remote-backend"
dynamodb_table = "org-tf-state-lock"
key = "three-tier/terraform.tfstate"
encrypt = true
region = "us-east-1"
}
}
module "networking" {
source = "./modules/networking"
vpc_cidr = var.vpc_cidr
subnets_cidrs = var.subnets_cidrs
azs = var.azs
access_ip = var.access_ip # your IP — never 0.0.0.0/0
}
module "loadbalancer" {
source = "./modules/loadbalancer"
alb_sg = module.networking.alb_sg
public_subnets = module.networking.public_subnets
vpc_id = module.networking.vpc_id
tg_port = 80
tg_protocol = "HTTP"
listener_port = 80
listener_protocol = "HTTP"
}
module "compute" {
source = "./modules/compute"
key_name = var.key_name
ami_value = var.ami_value
instance_type = var.instance_type
bastion_sg = module.networking.bastion_sg
web_tier_sg = module.networking.web_tier_sg
app_tier_sg = module.networking.app_tier_sg
public_subnets = module.networking.public_subnets
private_subnets = module.networking.private_subnets
target_group_arn = module.loadbalancer.target_group_arn
}
module "database" {
source = "./modules/database"
db_engine = var.db_engine
db_instance_class = var.db_instance_class
db_username = var.db_username
db_password = data.aws_ssm_parameter.db_password.value
db_subnet_group = module.networking.db_subnet_group_name
db_sg_id = module.networking.db_tier_sg
}
Every cross-module reference goes through an output. That is what creates the dependency graph — networking is built before the load balancer, which is built before compute.
Topic 4: Security Group Chaining
The pattern worth internalising: each tier accepts traffic only from the tier above, referenced by security group rather than by CIDR.
resource "aws_security_group" "web_tier" {
name = "web-tier-sg"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from load balancer only"
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
ingress {
description = "SSH from bastion only"
from_port = 22
to_port = 22
protocol = "tcp"
security_groups = [aws_security_group.bastion.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Referencing a security group instead of a CIDR means the rule stays correct when instances scale, restart or change address. A CIDR-based rule is a snapshot of a moment; a group-based rule is a statement about identity.
Topic 5: Subnets from One Indexed List
One variable drives three tiers across two availability zones:
resource "aws_subnet" "web_public" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
cidr_block = var.subnets_cidrs[count.index]
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
}
resource "aws_subnet" "app_private" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
cidr_block = var.subnets_cidrs[count.index + 2]
availability_zone = var.azs[count.index]
}
resource "aws_subnet" "db_private" {
count = length(var.azs)
vpc_id = aws_vpc.main.id
cidr_block = var.subnets_cidrs[count.index + 4]
availability_zone = var.azs[count.index]
}
subnets_cidrs = [
"10.0.1.0/24", "10.0.2.0/24", # web
"10.0.3.0/24", "10.0.4.0/24", # app
"10.0.5.0/24", "10.0.6.0/24", # db
]
This is count used correctly — the instances are genuinely identical and position carries meaning. Note the offset arithmetic is brittle if someone reorders the list; a map keyed by tier and AZ would be safer, and is a worthwhile refactor once the stack works.
The private route table must use nat_gateway_id, not gateway_id. gateway_id is for internet gateways, and the mistake produces a route that silently fails to work.
Topic 6: Instance Profile, Not Credentials
resource "aws_iam_role" "artifact_access" {
name = "artifact-access-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_policy" "artifact_access" {
name = "artifact-access-policy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
Resource = [
aws_s3_bucket.artifacts.arn,
"${aws_s3_bucket.artifacts.arn}/*"
]
}]
})
}
resource "aws_iam_instance_profile" "artifact_access" {
name = "artifact-access-profile"
role = aws_iam_role.artifact_access.name
}
Note the Resource list references the bucket’s real ARN. A hardcoded "arn:aws:s3:::*/*" grants access to every object in every bucket in the account — a mistake that appears in a lot of copied example code and passes every test you are likely to run.
Topic 7: The Migration Sequence
The part of this project worth doing carefully:
- Start with the backend block commented out. State is local; iterate quickly.
- Apply and verify the stack works — hit the load balancer endpoint, SSH through the bastion to the app tier, connect to the database.
- Create the state bucket and lock table (by hand or in a separate bootstrap project).
- Uncomment the backend block and run
terraform init. Terraform detects local state and offers to migrate. Confirm withyes. - Verify:
terraform.tfstateis gone locally; the object exists in the bucket. - Run
terraform plan. It must report no changes. This is the proof the remote backend sees the same infrastructure. If it proposes creating everything, stop — you are one apply away from a duplicate estate.
Topic 8: Cleaning Up
terraform destroy
Then verify the account is actually empty. Things that commonly survive a destroy:
- Objects in buckets that block deletion of a non-empty bucket — set
force_destroy = trueon scratch buckets. - Final database snapshots, if
skip_final_snapshot = false(which is correct for production and inconvenient here). - Elastic IPs and NAT gateways, which bill hourly regardless of traffic.
- Log groups, which have no cost signal until they do.
This check is the practical version of the teardown problem from lesson 1 — Terraform computes the order for you, but only for what it manages.
Try it yourself: After the stack is running, change one attribute inside the networking module and read the plan before applying. Trace how a change in one module propagates through the outputs into compute and the load balancer. That propagation is the payoff for wiring modules through outputs rather than duplicating values.
Common mistake: Setting access_ip = "0.0.0.0/0" to make the bastion easier to reach during development, then shipping it. That opens SSH to the entire internet on a host with network access to your private subnets — which is precisely the blast radius a bastion is supposed to contain.