Skip to main content

AWS 3-Tier on EC2 with Terraform and CI/CD

Build a 3-tier AWS stack with Terraform modules: VPC, ALB, EC2 Auto Scaling, and Multi-AZ RDS, deployed by a GitHub Actions pipeline.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project builds a three-tier AWS application with reusable Terraform modules: an Application Load Balancer in public subnets, an EC2 Auto Scaling Group in private subnets, and a Multi-AZ RDS MySQL database in isolated subnets. A GitHub Actions pipeline builds a Docker image, scans it, pushes it to ECR, and rolls it out with an ASG instance refresh. You finish with a repository you can show in a portfolio: modular infrastructure code plus a working deploy pipeline.

This is the EC2-based version of the three-tier pattern. You manage launch templates, instance roles, and rolling replacement yourself. If this is your first three-tier build, do Build a Three-Tier App on ECS Fargate with Terraform first: it uses containers on Fargate, flat Terraform files, and no pipeline, so you meet the network layout before the extra moving parts here.

What you build:

  • A VPC with public, private app, and private database subnets across two availability zones
  • An ALB with an HTTPS listener and an HTTP-to-HTTPS redirect
  • An Auto Scaling Group of EC2 instances that pull a Docker image from ECR
  • A Multi-AZ RDS MySQL instance whose password is managed by AWS Secrets Manager
  • A GitHub Actions pipeline with OIDC authentication, Trivy scanning, and a rolling instance refresh

What you learn: Terraform modules and remote state, security group chaining, launch templates and IAM instance profiles, target-tracking autoscaling, and rolling deployments without SSH access.

flowchart TD U[Internet users] --> ALB[ALB
public subnets] ALB --> ASG[EC2 Auto Scaling
private app subnets] ASG --> DB[(RDS MySQL
Multi-AZ, private)] GH[GitHub Actions] -->|push image| ECR[ECR] GH -->|instance refresh| ASG ECR -.->|pull image| ASG classDef edge fill:#F97316,color:#fff classDef app fill:#326CE5,color:#fff classDef data fill:#10B981,color:#fff classDef ci fill:#475569,color:#fff class ALB edge class ASG app class DB data class GH,ECR ci

The problem this solves

Teams that build AWS resources by hand in the console cannot answer basic questions: who changed that security group, why does staging differ from production, how do we rebuild after an incident. Here every resource lives in Git, so a new environment is a new folder under environments/ and a rebuild is terraform apply. No developer SSHes into production: code goes through GitHub, tests, a build, and a rolling replacement of instances. Treat zero-downtime as something to measure, not assume: Milestone 8 shows how.

Tip

Cost matters in this lab. Two NAT Gateways and a Multi-AZ RDS instance bill hourly. Run terraform destroy as soon as you finish a session.

Milestone 1: Create the Project Layout and Remote State

This milestone sets up a module-based folder structure and an S3 bucket for Terraform state, so state survives your laptop and two people cannot corrupt it by applying at once.

Bash
mkdir -p aws-3tier/modules/{vpc,security-groups,alb,asg,rds}
mkdir -p aws-3tier/environments/{staging,production}
cd aws-3tier
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
BUCKET=3tier-tfstate-$ACCOUNT_ID
aws s3api create-bucket --bucket $BUCKET --region ap-south-1 \
--create-bucket-configuration LocationConstraint=ap-south-1
aws s3api put-bucket-versioning --bucket $BUCKET \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption --bucket $BUCKET \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'

Recent Terraform versions (1.10 and later) lock state with a lock file in the same S3 bucket (use_lockfile = true), so you no longer need a DynamoDB table. Install Terraform 1.11 or newer.

Folder Purpose
modules/* Reusable building blocks, one per layer
environments/staging Calls the modules with small, cheap settings
environments/production Calls the same modules with production settings
Common Mistake

Putting both environments in one state file. Give each environment its own state key so a staging experiment can never plan changes against production.

Milestone 2: Build the VPC Module

This milestone creates the network: three subnet tiers per availability zone, an internet gateway, one NAT Gateway per zone, and route tables that decide what each tier can reach.

Each module declares its own variables.tf. The VPC module needs project, vpc_cidr, and availability_zones, and must output vpc_id, public_subnet_ids, private_app_subnet_ids, and private_db_subnet_ids.

modules/vpc/main.tf:

HCL
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "${var.project}-vpc" }
}
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index)
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = { Name = "${var.project}-public-${count.index}", Tier = "public" }
}
resource "aws_subnet" "private_app" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index + 2)
availability_zone = var.availability_zones[count.index]
tags = { Name = "${var.project}-app-${count.index}", Tier = "application" }
}
resource "aws_subnet" "private_db" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index + 4)
availability_zone = var.availability_zones[count.index]
tags = { Name = "${var.project}-db-${count.index}", Tier = "database" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
}
resource "aws_eip" "nat" {
count = length(var.availability_zones)
domain = "vpc"
}
resource "aws_nat_gateway" "main" {
count = length(var.availability_zones)
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].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_app" {
count = length(var.availability_zones)
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[count.index].id
}
}
# Database tier: no default route at all, not even through NAT
resource "aws_route_table" "private_db" {
vpc_id = aws_vpc.main.id
}
resource "aws_route_table_association" "public" {
count = length(var.availability_zones)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private_app" {
count = length(var.availability_zones)
subnet_id = aws_subnet.private_app[count.index].id
route_table_id = aws_route_table.private_app[count.index].id
}
resource "aws_route_table_association" "private_db" {
count = length(var.availability_zones)
subnet_id = aws_subnet.private_db[count.index].id
route_table_id = aws_route_table.private_db.id
}
Security

The database route table has no routes on purpose. The database only answers requests from the application tier and never needs to reach the internet.

Milestone 3: Build the Security Groups Module

This milestone creates the firewall rules that enforce the tier boundaries: the internet reaches only the ALB, the ALB reaches only the app, and the app reaches only the database.

The three groups reference each other, so the rules are separate resources. Inline ingress and egress blocks that point at each other create a Terraform dependency cycle. The module takes project and vpc_id and outputs alb_sg_id, app_sg_id, and db_sg_id.

modules/security-groups/main.tf:

HCL
resource "aws_security_group" "alb" {
name_prefix = "${var.project}-alb-"
vpc_id = var.vpc_id
}
resource "aws_security_group" "app" {
name_prefix = "${var.project}-app-"
vpc_id = var.vpc_id
}
resource "aws_security_group" "db" {
name_prefix = "${var.project}-db-"
vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "alb_https" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 443
to_port = 443
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "alb_to_app" {
security_group_id = aws_security_group.alb.id
referenced_security_group_id = aws_security_group.app.id
from_port = 8080
to_port = 8080
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 8080
to_port = 8080
ip_protocol = "tcp"
}
# The app needs outbound access for ECR, Secrets Manager, and the database
resource "aws_vpc_security_group_egress_rule" "app_all" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.app.id
from_port = 3306
to_port = 3306
ip_protocol = "tcp"
}
Common Mistake

Using a CIDR range such as 10.0.0.0/16 as a rule source. That admits everything in the VPC, including resources added later. Referencing a security group ID admits only resources that carry that group.

Milestone 4: Build the ALB Module

This milestone creates the public entry point: an Application Load Balancer that terminates HTTPS, redirects HTTP, and forwards to a target group that the Auto Scaling Group registers instances into.

The module takes project, vpc_id, public_subnet_ids, alb_security_group_id, and certificate_arn, and outputs target_group_arn and alb_dns_name. The certificate must be an ACM certificate in the same region, validated through DNS for a domain you own.

modules/alb/main.tf:

HCL
resource "aws_lb" "main" {
name = "${var.project}-alb"
load_balancer_type = "application"
security_groups = [var.alb_security_group_id]
subnets = var.public_subnet_ids
drop_invalid_header_fields = true
}
resource "aws_lb_target_group" "app" {
name = "${var.project}-app-tg"
port = 8080
protocol = "HTTP"
vpc_id = var.vpc_id
health_check {
path = "/health"
matcher = "200"
interval = 30
timeout = 10
healthy_threshold = 2
unhealthy_threshold = 3
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
resource "aws_lb_listener" "http_redirect" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
Tip

No domain for the lab? Swap the HTTPS listener for an HTTP listener on port 80 that forwards to the target group, and keep the HTTPS version for real use.

Milestone 5: Build the Auto Scaling Group Module

This milestone creates the application tier: an IAM role so instances can pull from ECR and read the database secret, a launch template that starts the container, and an Auto Scaling Group that replaces unhealthy instances and scales on CPU.

The module takes project, aws_region, instance_type, private_app_subnet_ids, app_security_group_id, target_group_arn, ecr_registry, app_image, db_endpoint, db_name, db_secret_arn, and the three size values. It outputs asg_name.

modules/asg/main.tf:

HCL
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-x86_64"]
}
}
resource "aws_iam_role" "app" {
name = "${var.project}-app-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "ecr_read" {
role = aws_iam_role.app.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}
# Session Manager replaces SSH
resource "aws_iam_role_policy_attachment" "ssm" {
role = aws_iam_role.app.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_role_policy" "read_db_secret" {
name = "read-db-secret"
role = aws_iam_role.app.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "secretsmanager:GetSecretValue"
Resource = var.db_secret_arn
}]
})
}
resource "aws_iam_instance_profile" "app" {
name = "${var.project}-app-profile"
role = aws_iam_role.app.name
}
resource "aws_launch_template" "app" {
name_prefix = "${var.project}-app-"
image_id = data.aws_ami.al2023.id
instance_type = var.instance_type
vpc_security_group_ids = [var.app_security_group_id]
iam_instance_profile {
name = aws_iam_instance_profile.app.name
}
user_data = base64encode(<<-EOF
#!/bin/bash
dnf install -y docker jq
systemctl enable --now docker
aws ecr get-login-password --region ${var.aws_region} | \
docker login --username AWS --password-stdin ${var.ecr_registry}
DB_PASSWORD=$(aws secretsmanager get-secret-value \
--secret-id ${var.db_secret_arn} --region ${var.aws_region} \
--query SecretString --output text | jq -r .password)
docker pull ${var.ecr_registry}/${var.app_image}:latest
docker run -d --name app --restart unless-stopped -p 8080:8080 \
-e DB_HOST=${var.db_endpoint} -e DB_NAME=${var.db_name} \
-e DB_PASSWORD="$DB_PASSWORD" \
${var.ecr_registry}/${var.app_image}:latest
EOF
)
# IMDSv2 only, which blocks the classic SSRF credential theft path
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
}
lifecycle {
create_before_destroy = true
}
}
resource "aws_autoscaling_group" "app" {
name = "${var.project}-app-asg"
vpc_zone_identifier = var.private_app_subnet_ids
target_group_arns = [var.target_group_arn]
health_check_type = "ELB"
health_check_grace_period = 120
min_size = var.min_size
max_size = var.max_size
desired_capacity = var.desired_capacity
launch_template {
id = aws_launch_template.app.id
version = aws_launch_template.app.latest_version
}
instance_refresh {
strategy = "Rolling"
preferences {
min_healthy_percentage = 50
}
}
tag {
key = "Name"
value = "${var.project}-app"
propagate_at_launch = true
}
}
resource "aws_autoscaling_policy" "cpu" {
name = "${var.project}-cpu-target"
autoscaling_group_name = aws_autoscaling_group.app.name
policy_type = "TargetTrackingScaling"
target_tracking_configuration {
predefined_metric_specification {
predefined_metric_type = "ASGAverageCPUUtilization"
}
target_value = 60
}
}

Health checks use ELB, so the ASG replaces an instance when the load balancer marks it unhealthy, not only when EC2 itself fails. A target-tracking policy keeps average CPU near 60 percent without hand-built alarms.

Common Mistake

Using version = "$Latest" in the ASG. The value never changes in your Terraform, so a new launch template version does not trigger an instance refresh. Reference latest_version instead.

Milestone 6: Build the RDS Module

This milestone creates the data tier: an encrypted, private MySQL instance with Multi-AZ failover, where AWS generates and stores the master password in Secrets Manager so it never appears in your code or state file.

The module takes project, private_db_subnet_ids, db_security_group_id, db_instance_class, db_name, db_username, multi_az, and protect_data. It outputs db_endpoint and db_secret_arn.

modules/rds/main.tf:

HCL
resource "aws_db_subnet_group" "main" {
name = "${var.project}-db-subnets"
subnet_ids = var.private_db_subnet_ids
}
resource "aws_db_instance" "main" {
identifier = "${var.project}-mysql"
engine = "mysql"
engine_version = "8.0"
instance_class = var.db_instance_class
allocated_storage = 20
storage_type = "gp3"
storage_encrypted = true
db_name = var.db_name
username = var.db_username
manage_master_user_password = true
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [var.db_security_group_id]
publicly_accessible = false
multi_az = var.multi_az
backup_retention_period = 7
deletion_protection = var.protect_data
skip_final_snapshot = !var.protect_data
final_snapshot_identifier = var.protect_data ? "${var.project}-final" : null
performance_insights_enabled = true
}
output "db_endpoint" {
value = aws_db_instance.main.address
}
output "db_secret_arn" {
value = aws_db_instance.main.master_user_secret[0].secret_arn
}
Tip

protect_data is true in production and false in staging. Production keeps deletion protection and a final snapshot; staging tears down cleanly. That is safer than a hard prevent_destroy that also blocks your lab cleanup.

Milestone 7: Wire the Production Environment

This milestone calls the five modules from one environment folder, passing outputs from one into inputs of the next so Terraform works out the creation order.

environments/production/main.tf:

HCL
terraform {
required_version = ">= 1.11.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
backend "s3" {
bucket = "3tier-tfstate-YOUR_ACCOUNT_ID"
key = "production/terraform.tfstate"
region = "ap-south-1"
encrypt = true
use_lockfile = true
}
}
provider "aws" {
region = "ap-south-1"
default_tags {
tags = { Project = var.project_name, Environment = "production", ManagedBy = "Terraform" }
}
}
module "vpc" {
source = "../../modules/vpc"
project = var.project_name
vpc_cidr = "10.0.0.0/16"
availability_zones = ["ap-south-1a", "ap-south-1b"]
}
module "security_groups" {
source = "../../modules/security-groups"
project = var.project_name
vpc_id = module.vpc.vpc_id
}
module "alb" {
source = "../../modules/alb"
project = var.project_name
vpc_id = module.vpc.vpc_id
public_subnet_ids = module.vpc.public_subnet_ids
alb_security_group_id = module.security_groups.alb_sg_id
certificate_arn = var.acm_certificate_arn
}
module "rds" {
source = "../../modules/rds"
project = var.project_name
private_db_subnet_ids = module.vpc.private_db_subnet_ids
db_security_group_id = module.security_groups.db_sg_id
db_instance_class = "db.t3.small"
db_name = var.db_name
db_username = var.db_username
multi_az = true
protect_data = true
}
module "asg" {
source = "../../modules/asg"
project = var.project_name
aws_region = "ap-south-1"
instance_type = "t3.small"
private_app_subnet_ids = module.vpc.private_app_subnet_ids
app_security_group_id = module.security_groups.app_sg_id
target_group_arn = module.alb.target_group_arn
ecr_registry = var.ecr_registry
app_image = var.app_image_name
db_endpoint = module.rds.db_endpoint
db_name = var.db_name
db_secret_arn = module.rds.db_secret_arn
min_size = 2
max_size = 8
desired_capacity = 2
}
output "alb_dns_name" {
value = module.alb.alb_dns_name
}
output "asg_name" {
value = module.asg.asg_name
}
output "db_identifier" {
value = "${var.project_name}-mysql"
}

Declare project_name, acm_certificate_arn, ecr_registry, app_image_name, db_name, and db_username in variables.tf, and set values in a terraform.tfvars that holds no secrets. Create the ECR repository and push a first image before the apply so instances have something to pull.

Bash
cd environments/production
terraform init
terraform plan -out=tfplan
terraform apply tfplan

Read the plan carefully, especially the security groups and the RDS settings. Expect 15 to 20 minutes, mostly for Multi-AZ RDS.

For staging, copy the folder, change the state key, use multi_az = false, protect_data = false, and a smaller desired_capacity. The module code stays identical, which is the point of modules.

Milestone 8: Deploy with GitHub Actions

This milestone adds a pipeline that tests, builds, scans, and pushes an image, then replaces the instances one batch at a time. It authenticates to AWS with OIDC, so no long-lived access keys sit in GitHub.

Create an IAM role for GitHub with a trust policy limited to your repository and a permissions policy limited to ECR push, autoscaling:StartInstanceRefresh, and autoscaling:DescribeInstanceRefreshes. Store the role ARN and ASG name as repository variables (AWS_DEPLOY_ROLE_ARN, ASG_NAME).

.github/workflows/deploy.yml:

YAML
name: Build and Deploy to AWS
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: |
npm ci
npm test
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ap-south-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build image
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
run: |
docker build -t $REGISTRY/my-app:${{ github.sha }} -t $REGISTRY/my-app:latest .
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: ${{ steps.login-ecr.outputs.registry }}/my-app:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1
- name: Push image
env:
REGISTRY: ${{ steps.login-ecr.outputs.registry }}
run: |
docker push $REGISTRY/my-app:${{ github.sha }}
docker push $REGISTRY/my-app:latest
- name: Roll out with instance refresh
env:
ASG_NAME: ${{ vars.ASG_NAME }}
run: |
REFRESH_ID=$(aws autoscaling start-instance-refresh \
--auto-scaling-group-name "$ASG_NAME" \
--preferences '{"MinHealthyPercentage":50,"InstanceWarmup":120}' \
--query InstanceRefreshId --output text)
while true; do
STATUS=$(aws autoscaling describe-instance-refreshes \
--auto-scaling-group-name "$ASG_NAME" \
--instance-refresh-ids "$REFRESH_ID" \
--query 'InstanceRefreshes[0].Status' --output text)
echo "Refresh status: $STATUS"
case "$STATUS" in
Successful) exit 0 ;;
Failed|Cancelled) exit 1 ;;
esac
sleep 30
done

The AWS CLI has no waiter for instance refreshes, so the last step polls the status itself and fails the job if the refresh fails. With MinHealthyPercentage at 50, at least half the fleet keeps serving while the other half is replaced.

Common Mistake

Pinning third-party actions to @master. A moving branch can change under you between runs. Pin to a released version tag, or a commit SHA for stricter control.

Validation & Testing

Run these checks after the apply and after your first pipeline run.

Bash
cd environments/production
# 1. Outputs exist
terraform output
# 2. The ALB responds (301 from HTTP, 200 from HTTPS)
ALB=$(terraform output -raw alb_dns_name)
curl -I http://$ALB
curl -kI https://$ALB
# 3. Every ASG instance is healthy and in service
aws autoscaling describe-auto-scaling-groups \
--auto-scaling-group-names $(terraform output -raw asg_name) \
--query 'AutoScalingGroups[0].Instances[*].[InstanceId,HealthStatus,LifecycleState]' \
--output table
# 4. RDS is Multi-AZ, private and encrypted
aws rds describe-db-instances \
--db-instance-identifier $(terraform output -raw db_identifier) \
--query 'DBInstances[0].[DBInstanceStatus,MultiAZ,PubliclyAccessible,StorageEncrypted]' \
--output table
# 5. No drift between code and AWS
terraform plan

Expected results: instances show Healthy and InService; RDS shows available, True, False, True; the final plan says no changes.

Test autoscaling by opening a Session Manager shell on one instance and generating CPU load, then watch the ASG add capacity:

Bash
aws ssm start-session --target INSTANCE_ID
# on the instance: run a CPU stress tool for a few minutes
aws autoscaling describe-scaling-activities \
--auto-scaling-group-name $(terraform output -raw asg_name) \
--max-items 5

Measure the deployment instead of assuming it is zero-downtime. In one terminal, request the app every second and log any failure. In another, push a commit to trigger the pipeline.

Bash
while true; do
CODE=$(curl -ks -o /dev/null -w "%{http_code}" https://$ALB/health)
echo "$(date +%T) $CODE"
sleep 1
done

Count the non-200 lines during the refresh and record the number in your project README. A result near zero is the outcome to aim for, and your own measurement is the evidence to show in an interview.

Common Mistake

Running terraform destroy on production settings and being blocked by deletion_protection. That is intentional. To remove the stack, set protect_data = false, apply, then destroy.

Found a mistake, an outdated step or a broken link?