Chapter 46: Terraform 3-Tier Architecture Multi-AZ with ALBs Auto Scaling Bastion NAT and RDS
46.6 Internal ALB and app tier Auto Scaling group
Story. Suppose we are separating the shop's business logic from the web pages, the way large retail sites do. App servers sit in private app subnets in both zones behind an internal ALB that has no public address. Web servers call the internal ALB, which spreads requests across healthy app servers. The app tier scales on its own, so a heavy checkout load does not need more web servers.
Steps
- Write app.tf and userdata/app.sh.
- Run terraform validate.
- After apply, curl /api/ several times and see answers from both zones.
# ---- app.tf ----
resource "aws_lb" "internal" {
name = "${var.project}-int-alb"
load_balancer_type = "application"
internal = true
security_groups = [aws_security_group.int_alb.id]
subnets = [for s in aws_subnet.app : s.id]
}
resource "aws_lb_target_group" "app" {
name = "${var.project}-app-tg"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
path = "/"
port = "8080"
matcher = "200"
}
}
resource "aws_lb_listener" "internal_http" {
load_balancer_arn = aws_lb.internal.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
resource "aws_launch_template" "app" {
name_prefix = "${var.project}-app-"
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.app.id]
metadata_options {
http_tokens = "required"
}
user_data = filebase64("${path.module}/userdata/app.sh")
}
resource "aws_autoscaling_group" "app" {
name = "${var.project}-app-asg"
min_size = 2
desired_capacity = 2
max_size = 6
vpc_zone_identifier = [for s in aws_subnet.app : s.id]
target_group_arns = [aws_lb_target_group.app.arn]
health_check_type = "ELB"
health_check_grace_period = 120
launch_template {
id = aws_launch_template.app.id
version = aws_launch_template.app.latest_version
}
instance_refresh {
strategy = "Rolling"
}
tag {
key = "Name"
value = "${var.project}-app"
propagate_at_launch = true
}
}
resource "aws_autoscaling_policy" "app_cpu" {
name = "app-cpu-60"
autoscaling_group_name = aws_autoscaling_group.app.name
policy_type = "TargetTrackingScaling"
target_tracking_configuration {
predefined_metric_specification {
predefined_metric_type = "ASGAverageCPUUtilization"
}
target_value = 60
}
}
# ---- userdata/app.sh ----
#!/bin/bash
mkdir -p /opt/app
echo "app tier: $(hostname)" > /opt/app/index.html
printf '%s\n' '[Unit]' 'Description=demo app' '[Service]' \
'ExecStart=/usr/bin/python3 -m http.server 8080 --directory /opt/app' \
'Restart=always' '[Install]' 'WantedBy=multi-user.target' \
> /etc/systemd/system/app.service
systemctl daemon-reload
systemctl enable --now app
What each line does
internal = true— No public IP; only reachable inside the VPC.subnets = [for s in aws_subnet.app : s.id]— The internal ALB lives in the private app subnets.port = 8080 (target group)— App servers listen on 8080.health_check { port = "8080" }— Health checks hit the app port.aws_lb_listener "internal_http" port = 80— Web calls the internal ALB on 80; it forwards to 8080.user_data = filebase64(".../app.sh")— The app boot script needs no variables, so filebase64 is enough.max_size = 6— The app tier can grow more than the web tier.health_check_type = "ELB"— Unhealthy app servers are replaced.app.sh: systemd unit— Runs the demo app on 8080 and restarts it if it stops. Replace it with your real app.
When it is green.
$ curl -s $(terraform output -raw app_url)/api/
app tier: ip-10-40-22-81.ap-south-1.compute.internal
$ curl -s $(terraform output -raw app_url)/api/
app tier: ip-10-40-21-37.ap-south-1.compute.internal
When it is red.
$ curl -s $(terraform output -raw app_url)/api/
<html><head><title>502 Bad Gateway</title></head>
Fix. This 502 comes from nginx on the web tier: it cannot reach the internal ALB, or the ALB has no healthy app targets. Check int_alb_from_web and app_from_int_alb, then check app target health.
What: every term in plain English
- internal ALB — A load balancer with internal = true. Its DNS name resolves only to private IPs.
- app tier — The business logic servers, listening on 8080 here.
- filebase64() — Reads a file and returns base64, ready for a launch template user_data.
- systemd unit — A service file that starts the app at boot and restarts it on crash.
- port mapping — The internal ALB listens on 80 and forwards to targets on 8080.
- decoupling — Web servers call one stable DNS name; app servers can scale or be replaced behind it.
- connection draining — deregistration_delay: how long the ALB waits for in-flight requests before removing a target.
- scale in protection — Stops the ASG from terminating a server that is still doing work.
Example 1: Create the internal ALB in the app subnets
At PhonePe, internal services talk through internal load balancers so a caller never needs the IP of a single server.
- Set internal = true.
- Use both app subnets.
- Give it the int_alb security group.
resource "aws_lb" "internal" {
name = "${var.project}-int-alb"
load_balancer_type = "application"
internal = true
security_groups = [aws_security_group.int_alb.id]
subnets = [for s in aws_subnet.app : s.id]
}
dig +short $(terraform output -raw internal_alb_dns)
Output
10.40.21.88
10.40.22.143
What it shows: Both answers are private 10.40.2x addresses, one per app subnet. From the internet this name is useless.
Example 2: Write the app boot script with printf
Course rule: no heredocs. printf with one quoted argument per line writes the systemd unit cleanly and passes shellcheck.
- Create userdata/app.sh.
- Write the unit file with printf.
- Load it with filebase64.
#!/bin/bash
# userdata/app.sh
mkdir -p /opt/app
echo "app tier: $(hostname)" > /opt/app/index.html
printf '%s\n' '[Unit]' 'Description=demo app' '[Service]' \
'ExecStart=/usr/bin/python3 -m http.server 8080 --directory /opt/app' \
'Restart=always' '[Install]' 'WantedBy=multi-user.target' \
> /etc/systemd/system/app.service
systemctl daemon-reload
systemctl enable --now app
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 = [aws_security_group.app.id]
user_data = filebase64("${path.module}/userdata/app.sh")
}
Output
$ shellcheck userdata/app.sh && echo clean
clean
What it shows: filebase64 is used because the script has no variables. Use templatefile when you need to inject values.
Example 3: Target group on 8080 with a health check
The internal ALB listens on 80 but the app listens on 8080. The target group port and the health check port must both be 8080.
- Create the target group on 8080.
- Health check path / on 8080.
- Set a short deregistration delay for labs.
resource "aws_lb_target_group" "app" {
name = "${var.project}-app-tg"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.main.id
deregistration_delay = 30
health_check {
path = "/"
port = "8080"
matcher = "200"
interval = 15
}
}
Output
aws_lb_target_group.app: Creation complete after 1s [id=arn:aws:elasticloadbalancing:ap-south-1:111122223333:targetgroup/shop3t-app-tg/def456]
What it shows: deregistration_delay 30 makes refreshes faster in a lab. Production often keeps the 300 second default.
Example 4: Test the full path web to app
The real proof is a request that enters the public ALB, passes nginx on a web server and lands on an app server.
- Curl /api/ on the public URL.
- Repeat to see different app hosts.
- Compare with web hostnames.
URL=$(terraform output -raw app_url)
for i in 1 2 3 4; do curl -s $URL/api/; done
Output
app tier: ip-10-40-21-34.ap-south-1.compute.internal
app tier: ip-10-40-22-91.ap-south-1.compute.internal
app tier: ip-10-40-21-34.ap-south-1.compute.internal
app tier: ip-10-40-22-91.ap-south-1.compute.internal
What it shows: Answers alternate between 10.40.21.x (zone a) and 10.40.22.x (zone b). Both tiers are balanced.
Example 5: Survive the loss of an app server
Self-healing is the reason for ASGs. Ola engineers test it by terminating a server in office hours and watching traffic carry on.
- Terminate one app instance.
- Keep curling /api/.
- Watch the ASG launch a replacement.
ID=$(aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names shop3t-app-asg \
--query "AutoScalingGroups[0].Instances[0].InstanceId" --output text)
aws ec2 terminate-instances --instance-ids $ID --query "TerminatingInstances[0].CurrentState.Name"
sleep 180
aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names shop3t-app-asg \
--query "length(AutoScalingGroups[0].Instances)"
Output
"shutting-down"
2
What it shows: During those three minutes every curl still answered, from the server in the other zone.
Example 6: Scale the app tier separately
At Paytm, payment logic needs more servers than the web front during a cashback offer. Separate ASGs let each tier grow on its own.
- Change app desired_capacity with a variable.
- Plan and apply.
- Confirm the web ASG did not change.
terraform apply -var app_desired=4 -auto-approve | grep -E "aws_autoscaling_group|Apply"
Output
aws_autoscaling_group.app: Modifying... [id=shop3t-app-asg]
aws_autoscaling_group.app: Modifications complete after 2s [id=shop3t-app-asg]
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
What it shows: Only the app ASG changed. In production let the policy scale and use ignore_changes on desired_capacity.
Command cheat sheet
| Command | What it does | When to use it |
|---|---|---|
aws_lb internal = true |
Private ALB | App tier |
filebase64(path) |
Base64 script | Static user_data |
aws_lb_target_group port = 8080 |
App targets | Backend port |
deregistration_delay |
Drain time | Faster refresh |
dig +short NAME |
Resolve DNS | Check internal IPs |
aws ec2 terminate-instances |
Kill a server | Self-healing test |
Verify: did it work?
-
Internal name is private
dig +short $(terraform output -raw internal_alb_dns)Output
10.40.21.88 / 10.40.22.143 -
API answers
curl -s $(terraform output -raw app_url)/api/Output
app tier: ip-10-40-2x... -
Two app servers
aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names shop3t-app-asg --query "length(AutoScalingGroups[0].Instances)"Output
2
When it is red 1
502 Bad Gateway
Cause. nginx on web cannot reach the internal ALB.
Fix, step by step
- Check int_alb ingress from the web group on 80.
- Check app_lb_dns in the rendered nginx file.
When it is red 2
Target.FailedHealthChecks: Health checks failed
Cause. The app did not start or the app group misses 8080 from int_alb.
Fix, step by step
- Read /var/log/cloud-init-output.log.
- Check app_from_int_alb rule.
When it is red 3
Error: Cycle: aws_launch_template.web, aws_lb.internal
Cause. A reference loop between resources.
Fix, step by step
- Make sure aws_lb.internal does not refer to web resources.
- Run terraform graph to find the loop.
Cleanup
terraform destroy -auto-approve
Ravindra Bagale's Tip
Name every tier target group and ASG with the tier in it (web, app). When an alarm fires at 2 a.m., the name alone tells you which tier is sick.
Practice tasks
Easy Resolve the internal name
Why: Private DNS.
What to do: Run dig on the internal ALB from a web server.
- SSH through the bastion.
Check: Two 10.40.2x IPs.
Easy Curl /api/ four times
Why: See balancing.
What to do: Count different app hosts.
- Loop with curl.
Check: Two hostnames.
Medium Terminate an app server
Why: Self-healing.
What to do: Kill one and keep curling.
- terminate-instances.
Check: No failed curl.
Hard Add a second app path
Why: Path routing.
What to do: Add a listener rule for /reports/ to a new target group.
- aws_lb_listener_rule.
Check: Requests split by path.
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
Add a third tier service, an "orders" API on port 9090, behind the same internal ALB with a path rule /orders/ and its own ASG.
Recap
- Internal ALB in app subnets, private IPs only.
- Boot script in userdata/app.sh loaded with filebase64.
- Listener 80, targets 8080.
- Web reaches app only through the internal ALB.
- Each tier scales and heals on its own.
FAQ
Why not call app servers by IP?
IPs change whenever the ASG replaces a server.
Can the internal ALB use HTTPS?
Yes, with a private certificate from ACM PCA.
Why filebase64 here and templatefile for web?
The app script has no variables.
Does the app tier need the bastion?
Only for debugging; Session Manager also works.
Interview questions
-
What is an internal load balancer?
A load balancer with only private IPs, used for traffic inside the VPC.
-
Why use separate ASGs for web and app?
Each tier has different load and can scale and deploy independently.
-
How does the web tier find the app tier?
Through the internal ALB DNS name passed with templatefile.
-
What is deregistration delay?
Time the ALB keeps sending in-flight requests to a target before removing it.
Quick quiz
Q1. App port?
8080
Q2. Internal ALB listener port?
80
Q3. Function for a static script?
filebase64
Q4. Restart setting in the unit?
Restart=always
Q5. Tool to resolve DNS?
dig
Samajla ka?
Revision card
Interview card