Last reviewed: May 2026
Build the AWS services on the SAA-C03 exam with plain Terraform — one block at a time, each tied back to an exam domain. The same code works on OpenTofu.
By the end of this lab you'll have provisioned, with plain Terraform, a textbook multi-AZ high-availability web stack — a custom VPC with public and private subnets across two AZs, an internet-facing Application Load Balancer, an Auto Scaling Group of EC2 instances in the private tier, and a multi-AZ RDS database for the app to talk to. Five exam pillars in five steps.
Every resource is plain Terraform — the same code works without modification on OpenTofu. No variables, no modules, no remote state. Drop the snippets into a single main.tf, run terraform init once, then terraform apply step-by-step.
>= 1.5 or OpenTofu >= 1.6.us-east-1 (default region for this lab — pick whatever you want, but the AMI lookup in Step 3 assumes the region has Amazon Linux 2023).skip_final_snapshot = true so destroy is clean — never use that setting on prod.Most of this lab is Free Tier or near it: the VPC, ALB, ASG, security groups, IAM, and CloudWatch monitoring are free. The two line items that bill while idle:
If you're cost-sensitive, set db_instance.multi_az = false for the lab and read the Multi-AZ docs separately. The exam still rewards knowing the attribute even if you don't run the full HA pattern. Destroy when you're done.
We pin AWS provider ~> 5.60 and pick us-east-1 (any region with at least two AZs works). The data block fetches the available AZ list at plan time — hardcoding AZ names like us-east-1a is a SAA-C03 anti-pattern because AZ-name-to-physical-zone mapping is account-specific (your us-east-1a may be a different physical AZ than mine).
All resources we create downstream tag back to a single project name via default_tags — Cost Explorer + AWS Config rules both work on tags, and the exam expects you to tag-everything.
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
Project = "certlabpro-saa-c03"
ManagedBy = "terraform"
}
}
}
data "aws_availability_zones" "available" {
state = "available"
}Every SAA-C03 high-availability question assumes you'll spread the workload across at least two Availability Zones. We create a /16 VPC and carve out four /20 subnets — two public (for the ALB), two private (for the EC2 instances and RDS). The internet gateway attaches to the VPC; the public route table sends 0.0.0.0/0 through it.
The private subnets deliberately do NOT have a NAT gateway in this lab — NAT GWs bill ~$32/month idle (per AZ) and SAA-C03 explicitly tests this trade-off in cost questions. If your app needed outbound internet from private subnets in production, you'd add a NAT (or a NAT instance, the SAA-C03 cheaper alternative).
The VPC + subnets + IGW + route table from this step are the canvas for everything that follows — the ALB plugs into the public subnets, the ASG instances live in the private ones, and the RDS subnet group in Step 5 spans both private AZs.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "certlabpro-saa-c03-vpc" }
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = { Name = "certlabpro-saa-c03-public-${count.index}" }
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 10}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "certlabpro-saa-c03-private-${count.index}" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
}
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_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}An Application Load Balancer is the load balancer SAA-C03 expects you to reach for whenever the question says "HTTP / HTTPS" or "path-based routing" or "target a specific service". Network Load Balancers handle TCP/UDP and ultra-low-latency; Classic is legacy. ALB it is.
The ALB lives in the two public subnets from Step 2 — that's what "multi-AZ" means structurally. Its security group accepts HTTP (port 80) from anywhere; the target group will get its members from the ASG we create in Step 4. The health check is the contract between the ALB and the target — if / returns non-200, the ALB stops sending traffic to that instance.
No HTTPS in this lab — that needs an ACM certificate and a DNS name, which is two more services we don't need to make the HA pattern visible. Add ACM in production.
resource "aws_security_group" "alb" {
name = "certlabpro-saa-c03-alb"
description = "ALB ingress on port 80 from the internet"
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_lb" "web" {
name = "certlabpro-saa-c03-alb"
load_balancer_type = "application"
internal = false
subnets = aws_subnet.public[*].id
security_groups = [aws_security_group.alb.id]
}
resource "aws_lb_target_group" "web" {
name = "certlabpro-saa-c03-tg"
port = 80
protocol = "HTTP"
target_type = "instance"
vpc_id = aws_vpc.main.id
health_check {
path = "/"
matcher = "200-299"
interval = 30
timeout = 5
healthy_threshold = 2
unhealthy_threshold = 3
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.web.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.web.arn
}
}Auto Scaling is the "elasticity" pillar of AWS Well-Architected and the second-most-tested topic on SAA-C03 after Multi-AZ. Combined with the ALB from Step 3, an ASG gives you both fault tolerance (instances replaced when they fail) and scalability (instance count tracks demand) — the two outcomes SAA-C03 keeps asking about.
The launch template defines what each new instance looks like. We use the latest Amazon Linux 2023 AMI, a t3.micro, the SG from Step 2-equivalent that accepts traffic only from the ALB SG (least-privilege at the network layer), and a minimal user-data that starts nginx so the health check passes.
The ASG ties three things together: where instances run (vpc_zone_identifier = our private subnets, both AZs), how many should exist (min_size = 2, max_size = 4 — two for HA, headroom for scaling), and how new instances join the load balancer (target_group_arns). When the ASG launches an instance, it's automatically registered with the target group from Step 3.
resource "aws_security_group" "app" {
name = "certlabpro-saa-c03-app"
description = "App instances accept traffic only from the ALB SG"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-kernel-6.1-x86_64"]
}
}
resource "aws_launch_template" "app" {
name_prefix = "certlabpro-saa-c03-app-"
image_id = data.aws_ami.al2023.id
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.app.id]
metadata_options {
http_tokens = "required"
}
user_data = base64encode(<<-EOT
#!/bin/bash
dnf install -y nginx
systemctl enable --now nginx
EOT
)
}
resource "aws_autoscaling_group" "app" {
name = "certlabpro-saa-c03-app-asg"
vpc_zone_identifier = aws_subnet.private[*].id
target_group_arns = [aws_lb_target_group.web.arn]
health_check_type = "ELB"
min_size = 2
max_size = 4
desired_capacity = 2
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
}The crown jewel of any SAA-C03 multi-AZ story is a Multi-AZ RDS database. The setting multi_az = true is one of the most-tested attributes on the entire exam: it tells RDS to provision a synchronous standby replica in a second AZ. When the primary fails, RDS automatically promotes the standby — typical failover takes ~60 seconds with no data loss. Two single-AZ databases would NOT give you that; manual replication does NOT give you that. Multi-AZ is the answer to any "sub-minute failover, zero data loss" question.
The DB subnet group needs subnets in at least two AZs — we feed it the private subnets from Step 2. The security group accepts the database port (5432 for PostgreSQL) only from the app SG, mirroring the layered-security pattern from Step 4.
With this final piece in place, the stack is complete: traffic enters the ALB in either public AZ, fans out to the ASG instances in either private AZ, and those instances talk to the RDS primary (which has a hot standby waiting in the other AZ). Anything except a total region failure has a recovery path.
resource "aws_db_subnet_group" "main" {
name = "certlabpro-saa-c03"
subnet_ids = aws_subnet.private[*].id
}
resource "aws_security_group" "db" {
name = "certlabpro-saa-c03-db"
description = "DB accepts traffic only from the app SG"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
}
resource "aws_db_instance" "main" {
identifier = "certlabpro-saa-c03"
engine = "postgres"
engine_version = "16"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_encrypted = true
multi_az = true # the SAA-C03 headline
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
username = "appuser"
password = "ChangeMe-Not-For-Prod-1234" # use Secrets Manager in production
skip_final_snapshot = true # lab-only setting; never use on prod
apply_immediately = true
}terraform destroy tears down everything in this lab. Two notes:
skip_final_snapshot = true, so destroy completes in a few minutes without leaving a snapshot behind. Never set that flag on a production database — losing the final snapshot is permanent data loss.destroy for the ASG itself doesn't complete until every instance is gone, which can take 2–3 minutes. Be patient.Ctrl-C the destroy mid-way — let it finish.SAA-C03 covers more architectural patterns than any single lab can demonstrate — CloudFront for global content distribution, Route 53 for DNS-based failover, S3 cross-region replication, Aurora Global Database, Direct Connect, VPC peering, Transit Gateway, ElastiCache, EFS, FSx, and many more.
We deliberately stick to the single most-tested pattern on the exam: multi-AZ HA within one region. That's the architecture that comes up in every Solutions-Architect interview and ~30% of SAA-C03 questions. The remaining 70% — global patterns, caching, advanced networking, hybrid cloud — are covered conceptually on the Browse and Editorial sections of this cert page. Each cross-region or advanced-networking topic deserves its own lab; building one mega-stack would dilute the build-story discipline this format depends on.
If you want hands-on practice with the cross-region patterns (S3 CRR, Aurora Global, Route 53 health checks), those map cleanly to the same plain-Terraform shape and are good candidates for follow-up labs.