AWS Three-Tier Web Architecture: Reference Design with Terraform
The classic three-tier architecture on AWS #
The three-tier pattern separates a web application into presentation (what receives traffic), application (the business logic) and data (the database). Each tier lives in its own subnets, scales on its own and only talks to its neighbor. It is the base design of the AWS with Terraform series.
Internet
│
[ Route 53 ] ──► [ ALB ] public subnets (2+ AZs) <- presentation
│
[ Auto Scaling EC2 ] private subnets (2+ AZs) <- application
│
[ RDS ] isolated subnets (2+ AZs) <- data
Components and where to learn them #
| Layer | Service | Tutorial |
|---|---|---|
| Network | VPC, subnets, route tables | VPC, subnets, routing |
| Internet access | Internet gateway and NAT gateway | IGW, NAT |
| Entry point | Route 53, ALB, ACM | Route 53, load balancers |
| Compute | EC2, Auto Scaling, AMI | EC2, Auto Scaling |
| Data | RDS Multi-AZ | RDS |
| Access control | Security groups, IAM | Security groups, IAM |
Design decisions #
High availability. Use at least two Availability Zones for every tier. The load balancer spreads traffic, the Auto Scaling group replaces failed instances, and RDS Multi-AZ fails over automatically.
Three layers of subnets. Public subnets only hold the load balancer and NAT gateways. Application instances are private, and the database subnets have no route to the Internet at all.
Security groups chained by reference. Instead of opening CIDR ranges, each tier only accepts traffic from the security group of the previous one:
resource "aws_security_group" "alb" {
name = "ditwl-pro-alb"
vpc_id = aws_vpc.main.id
}
resource "aws_vpc_security_group_ingress_rule" "alb_https" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "tcp"
from_port = 443
to_port = 443
}
resource "aws_security_group" "app" {
name = "ditwl-pro-app"
vpc_id = aws_vpc.main.id
}
resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.alb.id
ip_protocol = "tcp"
from_port = 8080
to_port = 8080
}
resource "aws_security_group" "db" {
name = "ditwl-pro-db"
vpc_id = aws_vpc.main.id
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.app.id
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
}Egress rules are omitted for brevity: security groups created with Terraform have no egress rules unless you add them.
No keys, no SSH. Instances use an IAM role and Session Manager for access, with VPC endpoints if there is no NAT.
Secrets outside the code. The database password is managed by Secrets Manager.
How to organize the Terraform code #
One module per layer, called from an environment directory (project structure):
module "network" {
source = "../../modules/network"
vpc_cidr = "10.10.0.0/16"
az_count = 2
}
module "database" {
source = "../../modules/database"
vpc_id = module.network.vpc_id
subnet_ids = module.network.database_subnet_ids
allowed_sg_id = module.app.security_group_id
multi_az = true
}
module "app" {
source = "../../modules/app"
vpc_id = module.network.vpc_id
private_subnet_ids = module.network.private_subnet_ids
public_subnet_ids = module.network.public_subnet_ids
domain_name = "www.example.com"
}Wire the outputs of each module to the inputs of the next (variables and outputs).
Operations #
- CloudWatch alarms on ALB 5xx errors, unhealthy hosts, CPU and database storage.
- Security monitoring with CloudTrail and GuardDuty.
- Deploy through CI/CD with OIDC.
- Backups: RDS automated backups, snapshots and a tested restore.
Costs #
The biggest items are the NAT gateways (one per AZ), the load balancer and Multi-AZ RDS, which are charged by the hour even when idle. A single NAT gateway saves money in development but is a single point of failure. See cost estimation.
Evolution #
When the application is containerized, replace the EC2 tier with ECS and Fargate or EKS. For event-driven parts consider Lambda and SQS. Static content moves to S3 and CloudFront.