Education › Certifications › Guided project

AWS Solutions Architect lab

The Solutions Architect Associate exam describes the same architecture over and over: two availability zones, a load balancer in front of an auto-scaling tier, a Multi-AZ database, static assets on S3 behind a CDN, least-privilege IAM, and a cost story. This lab builds exactly that on real AWS with Terraform, one exam domain per phase — secure, resilient, high-performing, cost-optimised — and after each phase you answer the exam questions that piece of the architecture is designed to test. You will actually fail a database over between availability zones and watch the application survive, actually trigger an auto-scaling event, actually see a CloudFront cache hit — because the exam rewards people who have watched these things happen, not just read about them. Then you tear it all down with one command and check the bill.

Associate about 6 hours 5 phases · 25 steps 0 / 25 done
What you will have at the end

A saa-lab repository whose Terraform builds the canonical three-tier architecture across two AZs — VPC with public/private subnets, an Application Load Balancer, an Auto Scaling group of EC2 instances behind it, a Multi-AZ RDS database, an S3 static-assets bucket behind CloudFront, and least-privilege IAM — with a Multi-AZ failover you triggered and observed, an auto-scaling event you triggered, a documented cost breakdown, a terraform destroy you ran, and a DRILLS.md of exam-style questions mapped to each thing you built and why.

Before you start
  • The Certifications track's AWS Solutions Architect guide — this lab is the hands-on companion to it; do the reading first
  • The DevOps track's Terraform, cloud and networking modules, and ideally the Terraform an AWS environment project, which set up the AWS account, a budget alert, remote state and the AWS CLI
  • An AWS account with a payment method and the AWS CLI logged in; expect roughly $1–3 for a session if you destroy the same day (a Multi-AZ RDS, an ALB and a NAT gateway are the paid pieces)
Tools you will install
  • Terraform 1.6+ — builds and destroys the whole architecture reproducibly, so you can spin it up to study and tear it down to stop paying ↗
  • AWS CLI v2 — triggering the failover, reading instance and RDS state, and confirming what Terraform built ↗
  • The AWS Console — the exam tests console familiarity too — you will read the ALB target health, RDS events and CloudFront metrics in the UI ↗
  • curl / hey — generating load to trigger auto-scaling and to observe the CDN cache ↗
  • The SAA exam guide — the domains and their weightings are the structure of this lab ↗
Repository layout at the end
saa-lab/
├── main.tf              # provider, data sources, tags
├── network.tf           # VPC, 2 public + 2 private subnets, IGW, NAT
├── security.tf          # security groups, IAM roles and instance profile
├── compute.tf           # launch template, Auto Scaling group, ALB
├── database.tf          # Multi-AZ RDS, subnet group, secret
├── cdn.tf               # S3 static bucket, CloudFront distribution, OAC
├── variables.tf
├── outputs.tf
├── terraform.tfvars     # git-ignored
├── DRILLS.md            # exam questions mapped to what you built
└── COST.md              # the bill, line by line

Tick each step as you finish it — your progress is saved in this browser only (back up or restore on the hub). Every code block has a copy button. If something goes wrong, the troubleshooting section at the end covers the usual suspects.

Phase 1

The reference architecture, and a guardrail

The architecture drawn and understood before you build it, a budget alert so a forgotten stack cannot surprise you, and the project skeleton.

  1. Draw the target before writing HCL. The SAA reference architecture is worth being able to sketch from memory: users reach CloudFront, which serves static assets from S3 and forwards dynamic requests to an ALB; the ALB spreads traffic across an Auto Scaling group of EC2 instances in private subnets across two AZs; those instances talk to a Multi-AZ RDS database, also private. Public subnets hold only the ALB and a NAT gateway.
    text
                        Internet
                           |
                      [ CloudFront ]  --(static)-->  [ S3 bucket ]
                           | (dynamic)
                      [   ALB   ]        public subnets (AZ-a, AZ-b)
                        /      \
                [ EC2 ]        [ EC2 ]   private subnets (Auto Scaling group)
                    \          /
                    [  RDS primary  ] --sync--> [ RDS standby ]   (Multi-AZ, private)
                       AZ-a                         AZ-b
    Every arrow in this diagram is an exam question. Why is RDS in a private subnet? Why does the ALB span two AZs? What happens to a request mid-failover? You will build each arrow and then answer for it.
  2. Set a budget alert first, before you create anything that costs money. This is both good practice and a Domain 4 (cost) exam topic.
    bash
    mkdir saa-lab && cd saa-lab && git init -b main
    ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
    cat > /tmp/budget.json <<EOF
    {"BudgetName":"saa-lab","BudgetLimit":{"Amount":"10","Unit":"USD"},"TimeUnit":"MONTHLY","BudgetType":"COST"}
    EOF
    cat > /tmp/notify.json <<EOF
    [{"Notification":{"NotificationType":"ACTUAL","ComparisonOperator":"GREATER_THAN","Threshold":50},"Subscribers":[{"SubscriptionType":"EMAIL","Address":"you@example.com"}]}]
    EOF
    aws budgets create-budget --account-id "$ACCOUNT" --budget file:///tmp/budget.json --notifications-with-subscribers file:///tmp/notify.json
    rm /tmp/budget.json /tmp/notify.json
    Put your real email in. The alert fires at 50% of $10. Destroying the stack the same day keeps you well under that; the alert exists so a forgotten NAT gateway over a weekend does not.
  3. Write the provider and variables. Two AZs is the exam's default answer to 'how many for high availability' — enough to survive one AZ failure without the cost of three.
    hcl
    # main.tf
    terraform {
      required_version = ">= 1.6"
      required_providers {
        aws    = { source = "hashicorp/aws", version = "~> 5.80" }
        random = { source = "hashicorp/random", version = "~> 3.6" }
      }
    }
    
    provider "aws" {
      region = var.region
      default_tags {
        tags = { project = "saa-lab", managed_by = "terraform" }
      }
    }
    
    data "aws_availability_zones" "available" {
      state = "available"
    }
    
    locals {
      azs = slice(data.aws_availability_zones.available.names, 0, 2)
    }
    
    # variables.tf
    variable "region" {
      type    = string
      default = "us-east-1"
    }
    
    variable "name" {
      type    = string
      default = "saa-lab"
    }
    
    variable "instance_type" {
      type    = string
      default = "t3.micro"
    }
    
    variable "db_instance_class" {
      type    = string
      default = "db.t4g.micro"
    }
Phase 2

Domain 1 — Secure architectures

The network and its access controls: a VPC with public and private subnets across two AZs, security groups that allow only what is needed, IAM roles the instances assume, and an S3 bucket that is private and encrypted.

  1. Write the network. The exam's mental model: public subnets have a route to an internet gateway; private subnets reach the internet only outbound, through a NAT gateway. Databases and app servers live in private subnets and are never directly reachable.
    hcl
    # network.tf
    resource "aws_vpc" "main" {
      cidr_block           = "10.0.0.0/16"
      enable_dns_support   = true
      enable_dns_hostnames = true
      tags                 = { Name = var.name }
    }
    
    resource "aws_internet_gateway" "main" {
      vpc_id = aws_vpc.main.id
    }
    
    resource "aws_subnet" "public" {
      count                   = 2
      vpc_id                  = aws_vpc.main.id
      cidr_block              = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
      availability_zone       = local.azs[count.index]
      map_public_ip_on_launch = true
      tags                    = { Name = "${var.name}-public-${count.index}", tier = "public" }
    }
    
    resource "aws_subnet" "private" {
      count             = 2
      vpc_id            = aws_vpc.main.id
      cidr_block        = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 10)
      availability_zone = local.azs[count.index]
      tags              = { Name = "${var.name}-private-${count.index}", tier = "private" }
    }
    
    resource "aws_eip" "nat" {
      domain = "vpc"
    }
    
    resource "aws_nat_gateway" "main" {
      allocation_id = aws_eip.nat.id
      subnet_id     = aws_subnet.public[0].id
      depends_on    = [aws_internet_gateway.main]
    }
    
    resource "aws_route_table" "public" {
      vpc_id = aws_vpc.main.id
      route {
        cidr_block = "0.0.0.0/0"
        gateway_id = aws_internet_gateway.main.id
      }
    }
    
    resource "aws_route_table" "private" {
      vpc_id = aws_vpc.main.id
      route {
        cidr_block     = "0.0.0.0/0"
        nat_gateway_id = aws_nat_gateway.main.id
      }
    }
    
    resource "aws_route_table_association" "public" {
      count          = 2
      subnet_id      = aws_subnet.public[count.index].id
      route_table_id = aws_route_table.public.id
    }
    
    resource "aws_route_table_association" "private" {
      count          = 2
      subnet_id      = aws_subnet.private[count.index].id
      route_table_id = aws_route_table.private.id
    }
    Exam trap to internalise now: a resource in a private subnet with no route to a NAT gateway cannot reach the internet, even with a public IP and an open security group. Reachability is subnet route table AND security group AND network ACL — all three, in that order.
  2. Write the security groups as a chain, which is the pattern the exam expects: the ALB accepts the internet on 80/443, the app accepts traffic only from the ALB's group, and the database accepts traffic only from the app's group. Each tier trusts only the tier in front of it.
    hcl
    # security.tf
    resource "aws_security_group" "alb" {
      name   = "${var.name}-alb"
      vpc_id = aws_vpc.main.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   = "${var.name}-app"
      vpc_id = aws_vpc.main.id
      ingress {
        from_port       = 80
        to_port         = 80
        protocol        = "tcp"
        security_groups = [aws_security_group.alb.id]   # only from the ALB
      }
      egress { from_port = 0, to_port = 0, protocol = "-1", cidr_blocks = ["0.0.0.0/0"] }
    }
    
    resource "aws_security_group" "db" {
      name   = "${var.name}-db"
      vpc_id = aws_vpc.main.id
      ingress {
        from_port       = 3306
        to_port         = 3306
        protocol        = "tcp"
        security_groups = [aws_security_group.app.id]   # only from the app tier
      }
      egress { from_port = 0, to_port = 0, protocol = "-1", cidr_blocks = ["0.0.0.0/0"] }
    }
    Referencing another security group as the source, rather than a CIDR, is the exam-preferred answer and the real-world one: the rule follows the instances wherever their IPs land, and it cannot accidentally allow a wider range.
  3. Write the IAM role the instances assume, so the app uses temporary credentials from the instance profile rather than any stored key. Reading Secrets Manager is scoped to the one secret. This is the 'never put credentials on an instance' principle the exam tests repeatedly.
    hcl
    # security.tf (continued)
    data "aws_iam_policy_document" "ec2_assume" {
      statement {
        actions = ["sts:AssumeRole"]
        principals {
          type        = "Service"
          identifiers = ["ec2.amazonaws.com"]
        }
      }
    }
    
    resource "aws_iam_role" "app" {
      name               = "${var.name}-app"
      assume_role_policy = data.aws_iam_policy_document.ec2_assume.json
    }
    
    resource "aws_iam_role_policy_attachment" "ssm" {
      role       = aws_iam_role.app.name
      policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"   # session access, no SSH keys
    }
    
    resource "aws_iam_instance_profile" "app" {
      name = "${var.name}-app"
      role = aws_iam_role.app.name
    }
  4. Write the S3 static-assets bucket, private and encrypted, with public access blocked. CloudFront will read it through an Origin Access Control in the CDN phase — the bucket itself is never public, which is the current best-practice answer (Origin Access Identity is the older one the exam may still show).
    hcl
    # cdn.tf (bucket now; distribution later)
    resource "random_id" "suffix" {
      byte_length = 4
    }
    
    resource "aws_s3_bucket" "static" {
      bucket = "${var.name}-static-${random_id.suffix.hex}"
    }
    
    resource "aws_s3_bucket_public_access_block" "static" {
      bucket                  = aws_s3_bucket.static.id
      block_public_acls       = true
      block_public_policy      = true
      ignore_public_acls      = true
      restrict_public_buckets = true
    }
    
    resource "aws_s3_bucket_server_side_encryption_configuration" "static" {
      bucket = aws_s3_bucket.static.id
      rule {
        apply_server_side_encryption_by_default {
          sse_algorithm = "AES256"
        }
      }
    }
  5. Initialise and apply just the secure foundation so far, and answer the Domain 1 drills before moving on.
    bash
    cat > terraform.tfvars <<'EOF'
    region = "us-east-1"
    EOF
    printf '.terraform/\n*.tfstate*\nterraform.tfvars\n.terraform.lock.hcl\n' > .gitignore
    terraform init
    terraform apply -auto-approve
    aws ec2 describe-subnets --filters Name=tag:project,Values=saa-lab \
      --query 'Subnets[].{az:AvailabilityZone,cidr:CidrBlock,tier:Tags[?Key==`tier`]|[0].Value}' --output table
    Domain 1 drills for DRILLS.md: (1) An EC2 instance in a private subnet cannot download OS updates — name three things to check, in order. (2) You must give an app read access to one S3 bucket. Instance profile role, or an access key in the AMI? Why? (3) The security team wants the database unreachable from the internet — which control(s) guarantee that? Write your answers, then check them against the guide.
  6. Verify the security posture the way an auditor (and the exam) frames it: prove the bucket blocks public access and is encrypted, and that the S3 origin is genuinely private. These CLI checks are the evidence behind the drill answers.
    bash
    BUCKET=$(aws s3 ls | grep saa-lab-static | awk '{print $3}')
    aws s3api get-public-access-block --bucket "$BUCKET" --query 'PublicAccessBlockConfiguration'
    aws s3api get-bucket-encryption --bucket "$BUCKET" --query 'ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault.SSEAlgorithm'
    # every value in the public-access block should be true; encryption should be AES256
    aws ec2 describe-security-groups --filters Name=group-name,Values=saa-lab-db \
      --query 'SecurityGroups[0].IpPermissions[0].UserIdGroupPairs[0].GroupId'   # db ingress is an SG, not a CIDR
    That the DB ingress rule references a security group id (not a CIDR) is the machine-checkable version of 'least privilege between tiers'. AWS Config rules and the exam both express security as exactly these assertions.
Phase 3

Domain 2 — Resilient architectures

A Multi-AZ database and an Auto Scaling group of app servers behind an ALB spanning both AZs — then a database failover you trigger and watch the app survive.

  1. Write the Multi-AZ database. multi_az = true is the single most-tested resilience feature: AWS keeps a synchronous standby in the other AZ and fails over automatically, usually in a minute or two, with the same endpoint DNS name — so the app does not change its connection string.
    hcl
    # database.tf
    resource "aws_db_subnet_group" "main" {
      name       = var.name
      subnet_ids = aws_subnet.private[*].id
    }
    
    resource "random_password" "db" {
      length  = 24
      special = false
    }
    
    resource "aws_db_instance" "main" {
      identifier              = var.name
      engine                  = "mysql"
      engine_version          = "8.0"
      instance_class          = var.db_instance_class
      allocated_storage       = 20
      storage_type            = "gp3"
      db_name                 = "app"
      username                = "admin"
      password                = random_password.db.result
      db_subnet_group_name    = aws_db_subnet_group.main.name
      vpc_security_group_ids   = [aws_security_group.db.id]
      multi_az                = true      # the exam's high-availability answer for RDS
      publicly_accessible     = false
      backup_retention_period = 1
      skip_final_snapshot     = true
      apply_immediately       = true
    }
    Know the distinction cold, because the exam lives on it: Multi-AZ is for *availability* (automatic failover, synchronous standby, same AZ region) — a read replica is for *scaling reads* (asynchronous, can be promoted manually, can be cross-region). If a question says 'high availability' the answer is Multi-AZ; if it says 'offload read traffic' it is a read replica.
  2. Write the launch template and Auto Scaling group. The app is a trivial page that reports which AZ and instance served it, so the load balancer spreading traffic is visible. The ASG spans both private subnets, so it keeps capacity in both AZs.
    hcl
    # compute.tf
    data "aws_ssm_parameter" "al2023" {
      name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
    }
    
    resource "aws_launch_template" "app" {
      name_prefix   = "${var.name}-"
      image_id      = data.aws_ssm_parameter.al2023.value
      instance_type = var.instance_type
      iam_instance_profile { arn = aws_iam_instance_profile.app.arn }
      vpc_security_group_ids = [aws_security_group.app.id]
      user_data = base64encode(<<-EOF
        #!/bin/bash
        dnf install -y httpd
        TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 60")
        AZ=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/placement/availability-zone)
        ID=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/instance-id)
        echo "<h1>saa-lab</h1><p>AZ: $AZ</p><p>instance: $ID</p>" > /var/www/html/index.html
        echo ok > /var/www/html/health
        systemctl enable --now httpd
      EOF
      )
    }
    
    resource "aws_autoscaling_group" "app" {
      name                = "${var.name}-app"
      min_size            = 2
      max_size            = 4
      desired_capacity    = 2
      vpc_zone_identifier = aws_subnet.private[*].id
      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               = "${var.name}-app"
        propagate_at_launch = true
      }
    }
    health_check_type = "ELB" matters: with it, the ASG replaces an instance the load balancer considers unhealthy, not just one that has crashed at the OS level. min_size = 2 across two AZs means one instance per AZ minimum — losing an AZ still leaves you serving.
  3. Write the ALB and its target group. The health check path is the /health file the user-data wrote; an instance that fails it is taken out of rotation and, because of the ELB health-check type, eventually replaced.
    hcl
    # compute.tf (continued)
    resource "aws_lb" "app" {
      name               = var.name
      load_balancer_type = "application"
      security_groups    = [aws_security_group.alb.id]
      subnets            = aws_subnet.public[*].id
    }
    
    resource "aws_lb_target_group" "app" {
      name     = var.name
      port     = 80
      protocol = "HTTP"
      vpc_id   = aws_vpc.main.id
      health_check {
        path                = "/health"
        healthy_threshold   = 2
        unhealthy_threshold = 2
        interval            = 10
      }
    }
    
    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
      }
    }
    
    # outputs.tf
    output "alb_dns" {
      value = "http://${aws_lb.app.dns_name}/"
    }
    
    output "db_endpoint" {
      value = aws_db_instance.main.address
    }
  4. Apply, wait for the instances to pass health checks, and confirm the ALB spreads requests across both AZs by hitting it repeatedly.
    bash
    terraform apply -auto-approve
    URL=$(terraform output -raw alb_dns)
    aws elbv2 describe-target-health --target-group-arn $(aws elbv2 describe-target-groups --names saa-lab --query 'TargetGroups[0].TargetGroupArn' --output text) \
      --query 'TargetHealthDescriptions[].TargetHealth.State'
    for i in $(seq 1 10); do curl -s "$URL" | grep -o 'AZ: [a-z0-9-]*'; done | sort | uniq -c
    You should see requests served from both AZs. If all come from one, give the second instance a minute to pass its health checks, or check the target group health — an instance that never becomes healthy usually failed its user-data (check the system log in the console).
  5. Now the resilience test the exam is really about: fail the database over between AZs and watch the app survive. RDS reboots onto the standby; the endpoint DNS stays the same, so nothing downstream reconfigures.
    bash
    aws rds describe-db-instances --db-instance-identifier saa-lab \
      --query 'DBInstances[0].{az:AvailabilityZone,multiaz:MultiAZ,status:DBInstanceStatus}'
    aws rds reboot-db-instance --db-instance-identifier saa-lab --force-failover
    # watch the events and the AZ change:
    sleep 30
    aws rds describe-events --source-identifier saa-lab --source-type db-instance --duration 10 \
      --query 'Events[].Message'
    aws rds describe-db-instances --db-instance-identifier saa-lab --query 'DBInstances[0].AvailabilityZone'
    The event log shows 'Multi-AZ instance failover started' and 'completed', and the AZ flips to the other zone — while the endpoint address never changed. That is the whole value of Multi-AZ, and now you have watched it. Domain 2 drills: (1) Multi-AZ vs read replica — when each? (2) An ASG has min 1, max 4 across two AZs; an AZ fails — what happens, and what is the resilience flaw? (3) Why health_check_type = ELB over EC2?
  6. Prove the app-to-database path actually works through the security-group chain, from inside a private instance with no SSH — using SSM Session Manager, the keyless access the IAM role granted. This is both the resilience proof (the standby answers on the same endpoint) and a Domain 1 payoff.
    bash
    DB=$(terraform output -raw db_endpoint)
    ID=$(aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names saa-lab-app \
      --query 'AutoScalingGroups[0].Instances[0].InstanceId' --output text)
    aws ssm send-command --instance-ids "$ID" --document-name AWS-RunShellScript \
      --parameters "commands=[\"timeout 5 bash -c '</dev/tcp/$DB/3306' && echo DB_REACHABLE || echo DB_UNREACHABLE\"]" \
      --query 'Command.CommandId' --output text
    # read the result after a few seconds:
    sleep 6
    aws ssm list-command-invocations --instance-id "$ID" --details \
      --query 'CommandInvocations[0].CommandPlugins[0].Output' --output text
    DB_REACHABLE from inside the private instance, while the database is unreachable from your laptop, is the tiered security model working: the app tier can reach the DB tier, nothing else can. That you did it over SSM — no SSH key, no bastion — is the least-privilege access answer the exam prefers.
Phase 4

Domain 3 — High-performing architectures

A CloudFront distribution serving S3 static content at the edge and forwarding dynamic requests to the ALB, plus a scaling policy you trigger with load.

  1. Upload a static asset and write the CloudFront distribution with two origins: the S3 bucket for static paths, the ALB for everything else. CloudFront caches static content at edge locations close to users — the exam's answer for 'reduce latency for a global audience' and 'offload the origin'.
    hcl
    # cdn.tf (continued)
    resource "aws_cloudfront_origin_access_control" "static" {
      name                              = "${var.name}-oac"
      origin_access_control_origin_type = "s3"
      signing_behavior                  = "always"
      signing_protocol                  = "sigv4"
    }
    
    resource "aws_cloudfront_distribution" "main" {
      enabled             = true
      default_root_object = "index.html"
    
      origin {
        domain_name              = aws_s3_bucket.static.bucket_regional_domain_name
        origin_id                = "s3"
        origin_access_control_id = aws_cloudfront_origin_access_control.static.id
      }
      origin {
        domain_name = aws_lb.app.dns_name
        origin_id   = "alb"
        custom_origin_config {
          http_port              = 80
          https_port             = 443
          origin_protocol_policy = "http-only"
          origin_ssl_protocols   = ["TLSv1.2"]
        }
      }
    
      default_cache_behavior {
        target_origin_id       = "alb"          # dynamic by default
        viewer_protocol_policy = "redirect-to-https"
        allowed_methods        = ["GET", "HEAD", "OPTIONS"]
        cached_methods         = ["GET", "HEAD"]
        cache_policy_id        = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" # Managed-CachingDisabled
      }
      ordered_cache_behavior {
        path_pattern           = "/static/*"    # static → S3, cached
        target_origin_id       = "s3"
        viewer_protocol_policy = "redirect-to-https"
        allowed_methods        = ["GET", "HEAD"]
        cached_methods         = ["GET", "HEAD"]
        cache_policy_id        = "658327ea-f89d-4fab-a63d-7e88639e58f6" # Managed-CachingOptimized
      }
    
      restrictions {
        geo_restriction { restriction_type = "none" }
      }
      viewer_certificate { cloudfront_default_certificate = true }
    }
    
    resource "aws_s3_bucket_policy" "static" {
      bucket = aws_s3_bucket.static.id
      policy = jsonencode({
        Version = "2012-10-17"
        Statement = [{
          Effect    = "Allow"
          Principal = { Service = "cloudfront.amazonaws.com" }
          Action    = "s3:GetObject"
          Resource  = "${aws_s3_bucket.static.arn}/*"
          Condition = { StringEquals = { "AWS:SourceArn" = aws_cloudfront_distribution.main.arn } }
        }]
      })
    }
    
    output "cdn_url" {
      value = "https://${aws_cloudfront_distribution.main.domain_name}/"
    }
    The bucket policy allows only this CloudFront distribution (by ARN) to read the bucket — the bucket stays private, and there is exactly one door into it. This OAC pattern replaced the older Origin Access Identity; the exam may show either, so recognise both.
  2. Apply, upload a static file, and prove the cache: the first request is a MISS from the origin, the second a HIT from the edge. CloudFront distributions take several minutes to deploy.
    bash
    terraform apply -auto-approve
    aws s3 cp <(echo '<h1>static asset from S3</h1>') "s3://$(terraform output -raw static_bucket 2>/dev/null || aws s3 ls | grep saa-lab-static | awk '{print $3}')/static/hello.html" --content-type text/html
    CDN=$(terraform output -raw cdn_url)
    echo "waiting for CloudFront to deploy (a few minutes)..."
    curl -s -D - "$CDN/static/hello.html" -o /dev/null | grep -i 'x-cache'
    curl -s -D - "$CDN/static/hello.html" -o /dev/null | grep -i 'x-cache'   # second time: Hit from cloudfront
    Add a static_bucket output (value = aws_s3_bucket.static.bucket) so the upload line is clean. The X-Cache header going from Miss from cloudfront to Hit from cloudfront is the CDN earning its place; the dynamic path (/) is always a miss because its cache policy is CachingDisabled.
  3. Add a target-tracking scaling policy: the ASG grows when average CPU crosses 50%. This is the exam's answer for 'handle variable load automatically' — you set the target metric, AWS manages the instance count.
    hcl
    # compute.tf (continued)
    resource "aws_autoscaling_policy" "cpu" {
      name                   = "${var.name}-cpu"
      autoscaling_group_name = aws_autoscaling_group.app.name
      policy_type            = "TargetTrackingScaling"
      target_tracking_configuration {
        predefined_metric_specification {
          predefined_metric_type = "ASGAverageCPUUtilization"
        }
        target_value = 50.0
      }
    }
  4. Apply, then drive load and watch the group scale out. Generating CPU on a t3.micro takes a sustained hammer; hey against the ALB plus a busy-loop on the instances via SSM does it.
    bash
    terraform apply -auto-approve
    URL=$(terraform output -raw alb_dns)
    # hammer the ALB, and separately spike CPU on the instances so the metric crosses 50%:
    hey -z 5m -c 50 "$URL" > /dev/null 2>&1 &
    for ID in $(aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names saa-lab-app --query 'AutoScalingGroups[0].Instances[].InstanceId' --output text); do
      aws ssm send-command --instance-ids "$ID" --document-name AWS-RunShellScript \
        --parameters 'commands=["for i in 1 2 3 4; do yes > /dev/null & done; sleep 240; pkill yes"]' >/dev/null
    done
    echo "watch the group grow toward max_size over the next few minutes:"
    watch -n 20 'aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names saa-lab-app --query "AutoScalingGroups[0].{desired:DesiredCapacity,instances:length(Instances)}"'
    Scaling is not instant — the CloudWatch alarm needs a few data points above 50%, then the ASG launches instances and waits for them to warm up. Watching desired tick from 2 to 3 to 4 is the point. Domain 3 drills: (1) Users in Europe complain a US-hosted site is slow — cheapest fix? (2) Target-tracking vs step scaling — when each? (3) Reads are the bottleneck, not writes — Multi-AZ, read replica, or ElastiCache?
Phase 5

Domain 4 — Cost-optimised architectures, and tear-down

The storage-class and lifecycle levers the exam tests, an honest cost breakdown of what you built, and a clean destroy so you stop paying.

  1. Add a lifecycle rule that moves old static objects to cheaper storage and expires very old ones. Storage classes are a reliable exam topic: S3 Standard for hot data, Standard-IA / Glacier tiers for cold, Intelligent-Tiering when access patterns are unknown.
    hcl
    # cdn.tf (continued)
    resource "aws_s3_bucket_lifecycle_configuration" "static" {
      bucket = aws_s3_bucket.static.id
      rule {
        id     = "tier-and-expire"
        status = "Enabled"
        filter {}
        transition {
          days          = 30
          storage_class = "STANDARD_IA"     # cheaper for infrequently accessed
        }
        transition {
          days          = 90
          storage_class = "GLACIER"          # archival, retrieval latency in exchange for price
        }
        expiration {
          days = 365
        }
      }
    }
    The exam decision tree: unknown or changing access pattern → Intelligent-Tiering (no retrieval fees, small monitoring cost). Known-cold with rare access → Standard-IA. Archive you might never read → Glacier Deep Archive. It rarely asks the price; it asks which class fits the described access pattern.
  2. Make the cost-versus-resilience trade concrete: apply a dev variant that drops the standby and uses one NAT, see the plan remove exactly the expensive resilient pieces, then apply the resilient version back. Watching the plan is the point — it shows precisely which resources cost you availability money.
    bash
    # add a variable: variable "resilient" { type = bool, default = true }
    # and wire it: multi_az = var.resilient  (on the RDS instance)
    terraform plan -var resilient=false | grep -E 'multi_az|# aws_db_instance' | head
    # the plan shows multi_az true -> false: one fewer instance, ~half the RDS cost, no automatic failover.
    # For a dev environment that is the right call; for prod it is not. Do NOT apply if you want the failover demo intact.
    This one variable is the entire Domain 2 / Domain 4 tension in the exam: the same architecture, resilient=true for prod (Multi-AZ, one NAT per AZ) and resilient=false for dev (single-AZ, one NAT). A good architect picks per environment; a good exam answer names the trade explicitly.
  3. Write the honest cost breakdown. Knowing which resources cost money — and which are effectively free — is Domain 4, and it is also how you avoid a surprise bill.
    text
    # COST.md — what this lab costs while running (us-east-1, 2026, approx.)
    
    | Resource               | Cost driver                       | ~ Hourly | Free tier?             |
    |------------------------|-----------------------------------|----------|------------------------|
    | RDS db.t4g.micro Multi-AZ | 2 instances (primary + standby)   | $0.034   | single-AZ only, 12 mo  |
    | NAT gateway            | per hour + per GB processed       | $0.045   | no                     |
    | Application Load Balancer | per hour + LCU                    | $0.023   | no                     |
    | 2 x EC2 t3.micro       | per hour each                     | $0.021   | 750 hrs/mo single, 12mo|
    | CloudFront             | per GB + per request              | ~$0      | 1 TB/mo, 12 mo         |
    | S3                     | storage + requests                | ~$0      | 5 GB, 12 mo            |
    | **Total while up**     |                                   | **~$0.12/hr** |                   |
    
    ## The expensive trio
    Multi-AZ RDS, the NAT gateway, and the ALB are the three that cost real money and have no free tier.
    A single-AZ RDS and one NAT is the cost-optimised dev version; Multi-AZ + one-NAT-per-AZ is the resilient prod
    version. That trade — this project's whole Domain 2 vs Domain 4 tension — is a frequent exam theme.
    
    ## Cost levers the exam tests
    - Right-size (t3.micro is already small); use Savings Plans / Reserved for steady prod load, Spot for interruptible.
    - One NAT gateway instead of two saves ~$32/mo but is a single point of failure — the resilience/cost trade again.
    - S3 lifecycle to IA/Glacier for cold data; Intelligent-Tiering when unsure.
    - Turn it off: `terraform destroy` is the ultimate cost optimisation for a lab.
  4. Confirm what is costing money right now with Cost Explorer (data lags ~a day, so also reason from COST.md), then destroy everything. The destroy plan lists every resource — read it, then confirm.
    bash
    aws ce get-cost-and-usage --time-period Start=$(date -u -v-1d +%F 2>/dev/null || date -d '1 day ago' +%F),End=$(date -u +%F) \
      --granularity DAILY --metrics UnblendedCost \
      --filter '{"Tags":{"Key":"project","Values":["saa-lab"]}}' \
      --query 'ResultsByTime[].Total.UnblendedCost.Amount' --output text 2>/dev/null || echo 'cost data not yet available (lags ~1 day)'
    terraform destroy   # read the plan, then type yes
    aws elbv2 describe-load-balancers --query 'LoadBalancers[?contains(LoadBalancerName,`saa-lab`)]' --output text
    aws rds describe-db-instances --query 'DBInstances[?DBInstanceIdentifier==`saa-lab`]' --output text
    The two checks after destroy should print nothing. RDS and CloudFront take the longest to delete (a Multi-AZ RDS is several minutes; CloudFront disables then deletes). If destroy fails on the S3 bucket being non-empty, empty it first: aws s3 rm s3://<bucket> --recursive then re-run.
  5. Rebuild-and-destroy is the study loop. Because the whole architecture is one terraform apply and one terraform destroy, you can stand it up to study a specific service, poke at it in the console, and tear it down — paying cents. Commit the code, DRILLS.md and COST.md so the lab is there whenever you want it.
    bash
    git add main.tf network.tf security.tf compute.tf database.tf cdn.tf variables.tf outputs.tf DRILLS.md COST.md .gitignore
    git commit -m 'saa-lab: three-tier reference architecture with exam drills and cost notes'
    gh repo create saa-lab --public --source=. --remote=origin --push
    # to study later: terraform apply -auto-approve ; ...poke... ; terraform destroy -auto-approve
  6. Fill in DRILLS.md by answering every drill from every phase in writing, then check each against the SAA guide. Writing the answer is what moves it from recognition to recall — which is what the exam measures.
    text
    # DRILLS.md — answer before checking the guide
    
    ## Domain 1 (secure)
    1. Private-subnet instance can't reach the internet: check (a) subnet route table has a 0.0.0.0/0 route to a NAT
       gateway, (b) the NAT is in a public subnet with an IGW route, (c) the security group allows outbound. In that order.
    2. App needs to read one S3 bucket → instance profile role scoped to that bucket. Never an access key in an AMI:
       keys leak, don't rotate, and can't be revoked without redeploying.
    3. DB unreachable from internet: private subnet (no IGW route) AND a security group that only allows the app SG.
    
    ## Domain 2 (resilient) — your answers
    ## Domain 3 (high-performing) — your answers
    ## Domain 4 (cost) — your answers
    
    ## Recurring exam patterns this lab embodies
    - 'Highly available' → span 2+ AZs; for RDS that means Multi-AZ.
    - 'Scale reads' → read replica or ElastiCache, NOT Multi-AZ.
    - 'Global low latency for static content' → CloudFront + S3.
    - 'Automatic capacity for variable load' → Auto Scaling with target tracking.
    - 'Least privilege' → IAM role on the instance, security group referencing another SG, private subnets.
Help

Troubleshooting

terraform apply hangs for many minutes on the RDS instance
A Multi-AZ RDS legitimately takes 5–10 minutes to create (it builds two instances and sets up replication). Terraform is waiting, not stuck. Watch progress with aws rds describe-db-instances --db-instance-identifier saa-lab --query 'DBInstances[0].DBInstanceStatus' — creating → backing-up → available.
ALB targets never become healthy
The health check hits /health on port 80; the instance must have finished user-data (installed httpd and written the file). Check the instance's system log in the console for user-data errors, confirm the app security group allows port 80 from the ALB security group, and that the target group's health-check path is exactly /health. Give it 2–3 minutes after the instance launches.
CloudFront returns 403 for the static object
Three things must line up: the OAC is attached to the S3 origin, the bucket policy allows cloudfront.amazonaws.com with the distribution ARN in the AWS:SourceArn condition, and the object exists at the requested key. A 403 with Missing Key is the object; a 403 from S3 is the policy or OAC. Distributions also take several minutes to deploy after a change — Status: Deployed in the console.
The Auto Scaling group does not scale out under load
Target tracking reacts to a CloudWatch alarm that needs several data points above the target, so expect a few minutes of sustained high CPU before an instance launches. Confirm CPU is actually high (aws cloudwatch get-metric-statistics on CPUUtilization); a hey load test alone may not spike CPU on a static page — the SSM yes loop in the step is what forces it.
terraform destroy fails on the S3 bucket: BucketNotEmpty
Terraform will not delete a bucket with objects unless force_destroy = true is set. Either empty it first (aws s3 rm s3://<bucket> --recursive) and re-run destroy, or add force_destroy = true to the bucket resource before the destroy (safe for a lab, dangerous in prod).
The failover reboot returns an error about the instance not being Multi-AZ
--force-failover requires multi_az = true and the instance to be available. Confirm both: aws rds describe-db-instances --db-instance-identifier saa-lab --query 'DBInstances[0].{az:MultiAZ,status:DBInstanceStatus}'. If it is still creating, wait for available.
Next

Where to go from here

Did a step fail or feel unclear? Tell me which one →