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.
- 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)
- 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 ↗
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 lineTick 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.
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.
- 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-bEvery 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. - 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.jsonPut 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. - 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" }
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.
- 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. - 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. - 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 } - 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" } } } - 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 tableDomain 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. - 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 CIDRThat 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.
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.
- Write the Multi-AZ database.
multi_az = trueis 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. - 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 = 2across two AZs means one instance per AZ minimum — losing an AZ still leaves you serving. - Write the ALB and its target group. The health check path is the
/healthfile 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 } - 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 -cYou 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). - 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) Whyhealth_check_type = ELBoverEC2? - 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 textDB_REACHABLEfrom 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.
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.
- 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. - 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 cloudfrontAdd astatic_bucketoutput (value = aws_s3_bucket.static.bucket) so the upload line is clean. TheX-Cacheheader going fromMiss from cloudfronttoHit from cloudfrontis the CDN earning its place; the dynamic path (/) is always a miss because its cache policy is CachingDisabled. - 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 } } - Apply, then drive load and watch the group scale out. Generating CPU on a t3.micro takes a sustained hammer;
heyagainst the ALB plus a busy-loop on the instances via SSM does it.bashterraform 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. Watchingdesiredtick 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?
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.
- 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. - Make the cost-versus-resilience trade concrete: apply a
devvariant 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=truefor prod (Multi-AZ, one NAT per AZ) andresilient=falsefor dev (single-AZ, one NAT). A good architect picks per environment; a good exam answer names the trade explicitly. - 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. - 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 textThe 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> --recursivethen re-run. - Rebuild-and-destroy is the study loop. Because the whole architecture is one
terraform applyand oneterraform 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.bashgit 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 - 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.
Troubleshooting
terraform applyhangs 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
/healthon 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.comwith the distribution ARN in theAWS:SourceArncondition, and the object exists at the requested key. A 403 withMissing Keyis the object; a 403 from S3 is the policy or OAC. Distributions also take several minutes to deploy after a change —Status: Deployedin 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-statisticsonCPUUtilization); aheyload test alone may not spike CPU on a static page — the SSMyesloop in the step is what forces it. terraform destroyfails on the S3 bucket:BucketNotEmpty- Terraform will not delete a bucket with objects unless
force_destroy = trueis set. Either empty it first (aws s3 rm s3://<bucket> --recursive) and re-run destroy, or addforce_destroy = trueto 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-failoverrequiresmulti_az = trueand the instance to beavailable. 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 foravailable.
Where to go from here
- Add HTTPS end to end: an ACM certificate on the ALB (or on CloudFront with a custom domain via Route 53), and redirect HTTP to HTTPS — a small change that closes the most common security-review gap and a frequent exam topic.
- Add a read replica and point read-only traffic at it, then compare its asynchronous replication behaviour with the Multi-AZ standby you already failed over — the two RDS features the exam contrasts most.
- Replace the EC2 tier with the DevOps track's ECS Fargate service to compare the serverless-container answer with the instance answer the exam presents as alternatives.
- Take a full-length SAA practice exam, and for every question you miss, decide whether this lab already contains the answer — most will — and go build or break that piece to fix the gap.
- Add AWS Config or a CloudFormation Guard / Terraform policy check that flags a public S3 bucket or an open security group, turning the security drills into automated guardrails.
Did a step fail or feel unclear? Tell me which one →