DevOps on AWS, part 6: One batch — the whole stack in Terraform
Part 6from the DevOps on AWS series · 6 parts in all
The finale. Everything from parts 1–5 — network, database, cache, cluster, load balancing,
logging — in one Terraform configuration that MapleCart can stand up with a single
terraform apply. This is deliberately abridged to the load-bearing lines (a real
file adds tags, variables, and TLS certs), but it is the real shape.
Structure
maplecart/
├── providers.tf # AWS provider + remote state
├── network.tf # VPC, subnets, NAT, security groups
├── data.tf # RDS + Redis
├── compute.tf # ECR, ECS cluster, task definition, service
├── edge.tf # ALB + target group + Route 53
└── outputs.tf # URLs and endpoints for humans
network.tf — the skeleton
resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" }
resource "aws_subnet" "private" {
count = 2 # two AZs, always
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
availability_zone = data.aws_availability_zones.all.names[count.index]
}
resource "aws_security_group" "db" {
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.web.id] # chain: db <- web <- alb
}
}
data.tf — database and cache
resource "aws_db_instance" "postgres" {
identifier = "maplecart-db"
engine = "postgres"
instance_class = "db.t3.medium"
allocated_storage = 50
multi_az = true # failover without human sacrifice
backup_retention_period = 14
storage_encrypted = true
db_subnet_group_name = aws_db_subnet_group.private.name
username = "maplecart"
password = random_password.db.result # generated, stored in Secrets Manager
skip_final_snapshot = false # production: keep the last snapshot
}
resource "aws_elasticache_replication_group" "redis" {
replication_group_id = "maplecart-cache"
engine = "redis"
node_type = "cache.t4g.small"
num_cache_clusters = 2
automatic_failover_enabled = true
}
compute.tf — containers on Fargate
resource "aws_ecs_cluster" "main" { name = "maplecart" }
resource "aws_ecs_task_definition" "web" {
family = "maplecart-web"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 512 # 0.5 vCPU
memory = 1024 # 1 GB
execution_role_arn = aws_iam_role.ecs_exec.arn # pulls image, writes logs
task_role_arn = aws_iam_role.app.arn # app's S3/Secrets permissions
container_definitions = jsonencode([{
name = "web"
image = "${aws_ecr_repository.web.repository_url}:latest"
portMappings = [{ containerPort = 8080 }]
logConfiguration = { logDriver = "awslogs",
options = { "awslogs-group" = "/maplecart/web", "awslogs-stream-prefix" = "app" } }
secrets = [{ name = "DB_PASSWORD",
valueFrom = aws_secretsmanager_secret.db.arn }] # injected, not env-committed
}])
}
resource "aws_ecs_service" "web" {
name = "web"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.web.arn
desired_count = 2 # two AZs
launch_type = "FARGATE"
deployment_minimum_healthy_percent = 100 # no downtime on deploys
load_balancer { target_group_arn = aws_lb_target_group.web.arn
container_name = "web" container_port = 8080 }
}
edge.tf — the front door
resource "aws_lb" "main" {
name = "maplecart"
load_balancer_type = "application"
subnets = aws_subnet.public[*].id # ALB lives in public subnets
security_groups = [aws_security_group.alb.id] # 443 from the world only
}
resource "aws_route53_record" "www" {
zone_id = data.aws_route53_zone.maplecart.zone_id
name = "www.maplecart.example"
type = "A"
alias { name = aws_lb.main.dns_name
zone_id = aws_lb.main.zone_id
evaluate_target_health = true }
}
Apply, verify, tear down
terraform init # downloads providers, connects remote state (S3 + DynamoDB lock)
terraform plan -out plan.tfplan # the review artifact: exactly what will change
terraform apply plan.tfplan # ~8 minutes: VPC, RDS, Redis, cluster, ALB
terraform destroy # practice environments: everything, gone, zero cost
The output of terraform plan is the culture shift in one screen: infrastructure
reviewed as a diff, applied reproducibly, and destroyed without a funeral. That is the whole
DevOps promise on AWS — and the same promise, we'll see, on Azure and Google Cloud, where the
service names change but the layered model from part 1 does not.