Chapter 46: Terraform 3-Tier Architecture Multi-AZ with ALBs Auto Scaling Bastion NAT and RDS
46.5 Internet-facing ALB and web tier Auto Scaling group
Story. Suppose we are getting ready for a festival sale on the shop. Traffic may triple in an hour, so the web tier is an Auto Scaling group across both private web subnets, behind an internet-facing ALB in the public subnets. If one web server fails its health check, the ALB stops sending traffic to it and the ASG replaces it. Each web server's nginx forwards /api/ to the internal ALB, whose DNS name Terraform writes into the boot script.
Steps
- Write web.tf and userdata/web.sh.tftpl.
- Run terraform validate.
- After apply, check web target health.
- Open app_url in a browser.
# ---- web.tf ----
resource "aws_lb" "external" {
name = "${var.project}-ext-alb"
load_balancer_type = "application"
internal = false
security_groups = [aws_security_group.ext_alb.id]
subnets = [for s in aws_subnet.public : s.id]
}
resource "aws_lb_target_group" "web" {
name = "${var.project}-web-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/"
matcher = "200"
}
}
resource "aws_lb_listener" "external_http" {
load_balancer_arn = aws_lb.external.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.web.arn
}
}
resource "aws_launch_template" "web" {
name_prefix = "${var.project}-web-"
image_id = data.aws_ami.al2023.id
instance_type = var.instance_type
key_name = aws_key_pair.ops.key_name
vpc_security_group_ids = [aws_security_group.web.id]
metadata_options {
http_tokens = "required"
}
user_data = base64encode(templatefile("${path.module}/userdata/web.sh.tftpl", {
app_lb_dns = aws_lb.internal.dns_name
}))
}
resource "aws_autoscaling_group" "web" {
name = "${var.project}-web-asg"
min_size = 2
desired_capacity = 2
max_size = 4
vpc_zone_identifier = [for s in aws_subnet.web : s.id]
target_group_arns = [aws_lb_target_group.web.arn]
health_check_type = "ELB"
health_check_grace_period = 120
launch_template {
id = aws_launch_template.web.id
version = aws_launch_template.web.latest_version
}
instance_refresh {
strategy = "Rolling"
}
tag {
key = "Name"
value = "${var.project}-web"
propagate_at_launch = true
}
}
resource "aws_autoscaling_policy" "web_cpu" {
name = "web-cpu-60"
autoscaling_group_name = aws_autoscaling_group.web.name
policy_type = "TargetTrackingScaling"
target_tracking_configuration {
predefined_metric_specification {
predefined_metric_type = "ASGAverageCPUUtilization"
}
target_value = 60
}
}
# ---- userdata/web.sh.tftpl ----
#!/bin/bash
dnf install -y nginx
echo "web tier: $(hostname)" > /usr/share/nginx/html/index.html
printf '%s\n' 'location /api/ {' \
' proxy_pass http://${app_lb_dns}/;' \
'}' > /etc/nginx/default.d/app.conf
systemctl enable --now nginx
What each line does
internal = false— Internet-facing: gets a public DNS name.subnets = [for s in aws_subnet.public : s.id]— Both public subnets, one per zone.aws_lb_target_group "web" health_check path "/"— A web server is healthy when / returns 200.aws_lb_listener "external_http"— Port 80 → web target group.aws_launch_template "web"— The recipe: AMI, size, key, web security group, IMDSv2.templatefile(".../web.sh.tftpl", { app_lb_dns = aws_lb.internal.dns_name })— Puts the internal ALB name into the nginx config.vpc_zone_identifier = [for s in aws_subnet.web : s.id]— Web servers in private web subnets a and b.target_group_arns = [aws_lb_target_group.web.arn]— The ASG registers every new server in the ALB.health_check_type = "ELB"— The ASG replaces servers that fail the ALB health check, not only dead ones.health_check_grace_period = 120— Gives nginx two minutes to start.version = aws_launch_template.web.latest_version— A template change creates a new version.instance_refresh { strategy = "Rolling" }— Then replaces servers a few at a time.target_value = 60— Scale out above 60% CPU.proxy_pass http://${app_lb_dns}/;— In the template file: /api/x goes to the internal ALB as /x.
When it is green.
$ aws elbv2 describe-target-health --target-group-arn <web-tg-arn> \
--query "TargetHealthDescriptions[].TargetHealth.State"
["healthy", "healthy"]
When it is red.
["unhealthy", "unhealthy"] Reason: Target.Timeout
Fix. The ALB cannot reach port 80 on the web servers. Check web_from_ext_alb and that nginx is running (SSH through the bastion and run systemctl status nginx). Private web servers need the NAT route to install nginx.
What: every term in plain English
- Application Load Balancer (ALB) — A layer 7 load balancer that reads HTTP requests and sends them to healthy targets.
- target group — The list of servers (targets) an ALB sends traffic to, with a health check.
- listener — The port and protocol the ALB listens on, with a default action such as forward to a target group.
- health check — A request the ALB sends to every target, for example GET / every 30 seconds. Failing targets get no traffic.
- launch template — The recipe for new servers: AMI, type, security group, key and user_data.
- desired capacity — How many servers the ASG keeps running right now, between min_size and max_size.
- target tracking — A scaling policy that adds or removes servers to keep a metric, such as average CPU, near a target.
- instance refresh — A rolling replacement of ASG servers when the launch template changes.
Example 1: Create the internet-facing ALB in both public subnets
An ALB needs subnets in at least two zones. Placing it in both public subnets is how Zomato keeps the front door open during a zone outage.
- Create aws_lb with internal = false.
- Give it both public subnets.
- Add a listener on port 80.
resource "aws_lb" "ext" {
name = "${var.project}-ext-alb"
load_balancer_type = "application"
internal = false
security_groups = [aws_security_group.ext_alb.id]
subnets = [for s in aws_subnet.public : s.id]
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.ext.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.web.arn
}
}
Output
aws_lb.ext: Still creating... [2m10s elapsed]
aws_lb.ext: Creation complete after 2m21s [id=arn:aws:elasticloadbalancing:ap-south-1:111122223333:loadbalancer/app/shop3t-ext-alb/abc123]
What it shows: ALBs take about two minutes. The for expression collects both public subnet ids in one line.
Example 2: Pass the internal ALB name into the web boot script
Web servers must know where the app tier is. templatefile() puts the internal ALB DNS name into nginx config, so nothing is hard-coded.
- Keep the script in userdata/web.sh.tftpl.
- Write the nginx file with printf, not a heredoc.
- Load it with templatefile and base64encode.
#!/bin/bash
# userdata/web.sh.tftpl
dnf install -y nginx
echo "web tier: $(hostname)" > /usr/share/nginx/html/index.html
printf '%s\n' 'location /api/ {' \
' proxy_pass http://${app_lb_dns}/;' \
'}' > /etc/nginx/default.d/app.conf
systemctl enable --now nginx
resource "aws_launch_template" "web" {
name_prefix = "${var.project}-web-"
image_id = data.aws_ami.al2023.id
instance_type = var.instance_type
key_name = aws_key_pair.ops.key_name
vpc_security_group_ids = [aws_security_group.web.id]
metadata_options {
http_tokens = "required"
}
user_data = base64encode(templatefile("${path.module}/userdata/web.sh.tftpl", {
app_lb_dns = aws_lb.internal.dns_name
}))
}
Output
# aws_launch_template.web will be created
+ user_data = (known after apply)
What it shows: Launch templates need base64 user_data. The value is known only after the internal ALB exists, so Terraform creates that first.
Example 3: Run the web ASG across both private web subnets
Two servers, one per zone, is the minimum for high availability. health_check_type = "ELB" lets the ASG replace servers that fail the ALB health check, not only dead ones.
- Set min 2, desired 2, max 6.
- Use both web subnets.
- Attach the web target group.
resource "aws_autoscaling_group" "web" {
name = "${var.project}-web-asg"
min_size = 2
desired_capacity = 2
max_size = 6
vpc_zone_identifier = [for s in aws_subnet.web : s.id]
target_group_arns = [aws_lb_target_group.web.arn]
health_check_type = "ELB"
health_check_grace_period = 120
launch_template {
id = aws_launch_template.web.id
version = aws_launch_template.web.latest_version
}
}
aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names shop3t-web-asg \
--query "AutoScalingGroups[0].Instances[].[AvailabilityZone,HealthStatus]" --output text
Output
ap-south-1a Healthy
ap-south-1b Healthy
What it shows: The ASG spreads servers evenly across zones. grace 120 gives user_data two minutes before health checks count.
Example 4: Watch the target group become healthy
Before calling a release done, Myntra engineers check that every target is healthy. A curl that works once may hit only one good server.
- Get the target group ARN.
- Describe target health.
- Wait until both say healthy.
TG=$(terraform state show aws_lb_target_group.web | awk '/^ *arn /{print $3}' | tr -d '"')
aws elbv2 describe-target-health --target-group-arn $TG \
--query "TargetHealthDescriptions[].[Target.Id,TargetHealth.State]" --output text
Output
i-0web0a1b2c3d4e5f6 healthy
i-0web0b2c3d4e5f6a7 healthy
What it shows: States go initial, then healthy. unhealthy usually means nginx did not start or the web group misses the ALB rule.
Example 5: Scale on CPU with target tracking
During a Big Billion Days sale at Flipkart, load jumps in minutes. A target tracking policy adds web servers automatically to keep average CPU near 60 percent.
- Add a target tracking policy.
- Load the servers.
- Watch desired capacity grow.
resource "aws_autoscaling_policy" "web_cpu" {
name = "web-cpu-60"
autoscaling_group_name = aws_autoscaling_group.web.name
policy_type = "TargetTrackingScaling"
target_tracking_configuration {
predefined_metric_specification {
predefined_metric_type = "ASGAverageCPUUtilization"
}
target_value = 60
}
}
# on each web server, through the bastion
sudo dnf install -y stress-ng && stress-ng --cpu 2 --timeout 600 &
# on your laptop after 5 minutes
aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names shop3t-web-asg \
--query "AutoScalingGroups[0].DesiredCapacity"
Output
4
What it shows: The ASG added two servers. When stress-ng stops, it scales back in after a cool-down, never below min_size 2.
Example 6: Roll out a new web version with instance refresh
Changing user_data creates a new launch template version. instance_refresh replaces servers a few at a time so users never see downtime.
- Add instance_refresh to the ASG.
- Change the index.html text.
- Apply and watch the refresh.
instance_refresh {
strategy = "Rolling"
preferences {
min_healthy_percentage = 50
}
}
terraform apply -auto-approve
aws autoscaling describe-instance-refreshes --auto-scaling-group-name shop3t-web-asg \
--query "InstanceRefreshes[0].[Status,PercentageComplete]" --output text
Output
InProgress 50
Successful 100
What it shows: min_healthy_percentage 50 keeps at least one of two servers in service at every moment.
Command cheat sheet
| Command | What it does | When to use it |
|---|---|---|
aws_lb internal = false |
Public ALB | Front door |
aws_lb_target_group |
Targets + health check | Each tier |
aws_lb_listener |
Port 80 forward | Route traffic |
base64encode(templatefile(...)) |
Launch template user_data | Web boot |
health_check_type = "ELB" |
Replace unhealthy | ASG with ALB |
aws elbv2 describe-target-health |
Target state | Verify |
Verify: did it work?
-
ALB answers
curl -s http://$(terraform output -raw app_url | sed "s#http://##")/Output
web tier: ip-10-40-11-57... -
Two healthy targets
aws elbv2 describe-target-health --target-group-arn $TG --query "length(TargetHealthDescriptions)"Output
2 -
Servers in both zones
aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names shop3t-web-asg --query "AutoScalingGroups[0].AvailabilityZones"Output
["ap-south-1a","ap-south-1b"]
When it is red 1
Error: creating ELBv2 Load Balancer: ValidationError: At least two subnets in two different Availability Zones must be specified
Cause. Only one subnet, or two in the same zone, was passed.
Fix, step by step
- Use both public subnets.
- Check local.azs has two zones.
When it is red 2
503 Service Temporarily Unavailable
Cause. The target group has no healthy targets yet.
Fix, step by step
- Wait for the grace period.
- Check nginx and the web group ingress from the ALB.
When it is red 3
Error: Invalid template interpolation value: vars map does not contain key "app_lb_dns"
Cause. The template variable name does not match the map.
Fix, step by step
- Pass app_lb_dns in templatefile.
- Spell it the same in the .tftpl file.
Cleanup
terraform destroy -auto-approve
Ravindra Bagale's Tip
Always use health_check_type = "ELB" when an ASG sits behind a load balancer. With the default EC2 check, a server whose nginx has crashed stays in the group forever.
Practice tasks
Easy Curl it ten times
Why: See load balancing.
What to do: Curl the ALB ten times and count hostnames.
- for i in $(seq 10); do curl -s URL; done
Check: Two different hostnames.
Easy Read target health
Why: Know the states.
What to do: Run describe-target-health.
- Get the TG ARN.
Check: Both healthy.
Medium Kill a server
Why: Self-healing.
What to do: Terminate one web instance and watch the ASG.
- aws ec2 terminate-instances.
Check: A new one appears in 2 minutes.
Hard Add HTTPS
Why: Production front door.
What to do: Add an ACM certificate and a 443 listener.
- aws_acm_certificate.
Check: https works.
Easy Say it in two lines
Why: If you can say it simply, you understand it.
What to do: Write two short lines about this lesson for a fresher.
- Write the problem it solves.
- Write the one command or file you use.
- Read it aloud.
Check: A fresher understands it without a question.
Easy Start a safe lab folder
Why: Practice needs a throwaway place.
What to do: Make a new folder with versions.tf and a random_pet resource, so no AWS cost.
- mkdir tf-lab and cd into it.
- Add the hashicorp/random provider and one random_pet.
- Run terraform init and terraform apply.
Check: Apply complete! with 1 added.
Medium Break it on purpose
Why: You must know the red output by face.
What to do: Cause one error from the red part of this lesson and fix it.
- Follow one red section.
- Copy the exact message to your notes.
- Write the fix in one line.
Check: You fixed it without searching the web.
Medium Run it twice
Why: A second run must be quiet.
What to do: Run the same plan two times with no edits.
- Run terraform plan.
- Run it again.
- Explain any change it still shows.
Check: The second plan says No changes, or you can explain each line.
Medium Write a runbook page
Why: At 2 a.m. nobody remembers flags.
What to do: Write a one-page runbook for this lesson.
- Title, when to use, exact commands, how to check, how to undo.
Check: A teammate can follow it alone.
Medium Run it from a pipeline
Why: Real teams run Terraform from CI, not from laptops.
What to do: Run the main command of this lesson inside a GitHub Actions or Jenkins job.
- Add a job with hashicorp/setup-terraform or a Terraform Docker image.
- Give it AWS access through an IAM role, never a key in the file.
- Run init, then the command from this lesson.
Check: The job log shows the same output you saw on your laptop.
Medium Check the cost
Why: Labs left running cost real money.
What to do: Find what this lesson would cost per day if you forgot to destroy it.
- Open the AWS Pricing Calculator.
- Add each resource from your plan.
- Write the daily cost in your notes.
Check: You know the daily cost and you ran terraform destroy.
Hard Draw the flow top to bottom
Why: A drawing shows the order of work.
What to do: Draw this lesson as boxes from top to bottom.
- Start at your code, go through plan and state, end at AWS.
- Mark where each command reads or writes.
Check: Each box matches a real step you ran.
Hard Explain it like an interview
Why: Interviewers want real stories.
What to do: Record a 60 second answer about this lesson.
- Say the problem, the command, and one error you hit.
Check: Your answer has a real command and a real error message.
Homework: try at home
Try at home
Write a web tier for a Netflix-style catalogue site: external ALB, launch template with a templatefile boot script, and an ASG of 2 to 8 servers scaling at 50 percent CPU.
Recap
- External ALB in both public subnets.
- Web servers stay in private subnets.
- templatefile passes the internal ALB name.
- ELB health checks drive replacement.
- Target tracking and instance refresh handle load and releases.
FAQ
Why do web servers have no public IP?
Users reach them only through the ALB.
Why latest_version?
So a new template version is used by the ASG and refresh.
Can I use a Network Load Balancer?
Yes for TCP; an ALB is better for HTTP paths.
How long does a refresh take?
A few minutes per batch, depending on grace period.
Interview questions
-
What is the difference between a launch template and an ASG?
The template is the recipe; the ASG decides how many servers and where.
-
Why set health_check_type to ELB?
So the ASG replaces servers that fail application health checks.
-
How does target tracking work?
It adjusts capacity to keep a metric near the target value.
-
How do you deploy a new AMI with no downtime?
Update the launch template and run a rolling instance refresh.
Quick quiz
Q1. ALB needs subnets in how many zones?
At least two
Q2. ASG min in this lab?
2
Q3. CPU target?
60
Q4. Grace period?
120 seconds
Q5. Refresh strategy?
Rolling
Samajla ka?
Revision card
Interview card