Trust is earned, not given

A different perspective

2025-02-18 · Projects

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.