Wiring an ALB + Auto Scaling Group Together
An Application Load Balancer (ALB, a smart traffic distributor for HTTP and HTTPS) and an Auto Scaling Group (ASG, the service that automatically adds and removes EC2 servers) are great on their own, but together they form the classic, production-grade web tier on AWS. The ALB gives you one stable front door and spreads requests across many servers; the ASG keeps the right number of those servers running, replaces broken ones, and grows or shrinks the fleet with load. This page walks through wiring the two together end to end — a public-facing ALB, a private fleet of instances, ELB health checks, and target-tracking scaling — and calls out the two mistakes that quietly break this pattern.
The architecture at a glance
The standard layout puts each piece in the right place inside your VPC (Virtual Private Cloud, your private network in AWS):
- ALB in public subnets — the load balancer needs internet-facing IPs, so it lives in public subnets (subnets with a route to an internet gateway) across two or more Availability Zones (AZs, isolated data centers in a region). Only the ALB is exposed to the internet.
- EC2 instances in private subnets — your application servers (EC2 instances, the virtual machines you rent) sit in private subnets with no public IPs. The internet can’t reach them directly; only the ALB can. This is the safer design.
- ASG manages the instances — the ASG launches instances from a launch template into those private subnets and registers each one with the ALB’s target group automatically.
- Target group — the list of instances the ALB forwards traffic to, along with the health check that decides which instances are allowed to receive requests.
The request path is: user to ALB (public) to a healthy instance (private) and back.
When to use this pattern (and when not)
| Use it when | Skip it when |
|---|---|
| You run a stateless web or API tier that benefits from horizontal scaling. | You have a single, always-on instance with no scaling needs (just use the instance directly). |
| Traffic varies through the day or week and you want to pay only for what you use. | The workload is a long-running batch job with no inbound HTTP traffic. |
| You need high availability across AZs and automatic replacement of failed servers. | You need raw TCP/UDP or ultra-low latency — use a Network Load Balancer (NLB) instead. |
Step 1 — Build the ALB and an empty target group
You need the ALB, a listener, and a target group before the ASG can attach to anything. Create the target group empty — do not add instances by hand. The ASG will populate it.
Console steps:
- Open the EC2 console, choose Target Groups, then Create target group. Choose target type Instances, name it
web-tg, set protocol HTTP port 80, and pick your VPCvpc-0a1b2c3d. - Under Health checks, set the path your app responds to (e.g.
/health). Leave the targets list empty and choose Create target group. - Choose Load Balancers, then Create load balancer, and pick Application Load Balancer.
- Name it
web-alb, scheme Internet-facing, and select your public subnets in two AZs (e.g.subnet-0a1b2c3d,subnet-0b2c3d4e). - Attach a security group for the ALB (e.g.
sg-0a1b2c3d) that allows inbound port 80/443 from0.0.0.0/0. - Add a listener on HTTP:80 that forwards to
web-tg, then Create load balancer.
CLI equivalent:
aws elbv2 create-target-group \
--name web-tg \
--protocol HTTP --port 80 \
--vpc-id vpc-0a1b2c3d \
--target-type instance \
--health-check-path /health
Output:
{
"TargetGroups": [
{
"TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/web-tg/abc123def456",
"TargetGroupName": "web-tg",
"Protocol": "HTTP",
"Port": 80,
"HealthCheckPath": "/health"
}
]
}
Step 2 — Fix the security groups (the #1 gotcha)
The instances live in private subnets, so the only thing that should talk to them is the ALB. Their security group (a virtual firewall) must allow inbound traffic on the app port from the ALB’s security group — not from a CIDR range, and not from 0.0.0.0/0. Referencing the ALB’s security group by ID is the correct, durable way to do this.
aws ec2 authorize-security-group-ingress \
--group-id sg-0b2c3d4e \
--protocol tcp --port 80 \
--source-group sg-0a1b2c3d
Here sg-0b2c3d4e is the instance security group and sg-0a1b2c3d is the ALB security group.
Gotcha: if the instance security group does not allow the ALB’s security group on the app port, every target will show as unhealthy even though the app is running fine. The ALB’s health checks simply can’t reach the instance. This is the single most common reason a freshly built ALB+ASG returns
503 Service Unavailable.
Step 3 — Attach the ASG to the target group
This is where the two services connect. The ASG must register itself with the target group so that every instance it launches is added automatically and every instance it terminates is removed. You do this on the ASG, by passing the target group ARN — never by manually adding instances to the target group.
Console steps:
- In Auto Scaling Groups, create or edit
web-app-asg. - Choose your launch template (which references the private-subnet instance security group
sg-0b2c3d4e). - Select your private subnets in two AZs.
- On the load balancing step, choose Attach to an existing load balancer, then Choose from your load balancer target groups, and select
web-tg. - Turn on Turn on Elastic Load Balancing health checks and set a health check grace period of 120 seconds.
CLI equivalent:
aws autoscaling create-auto-scaling-group \
--auto-scaling-group-name web-app-asg \
--launch-template "LaunchTemplateName=web-app-lt,Version=\$Latest" \
--min-size 2 --max-size 6 --desired-capacity 2 \
--vpc-zone-identifier "subnet-0c3d4e5f,subnet-0d4e5f6a" \
--target-group-arns "arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/web-tg/abc123def456" \
--health-check-type ELB \
--health-check-grace-period 120
Gotcha: if you skip
--target-group-arnsand instead register your current instances into the target group by hand, those manual targets receive traffic, but every new instance the ASG launches during scale-out will not join the load balancer. Your fleet grows but the extra capacity never serves a single request. Always wire the target group through the ASG.
Setting --health-check-type ELB tells the ASG to trust the ALB’s verdict: if the ALB marks an instance unhealthy, the ASG terminates and replaces it. That is what makes the tier self-healing.
Step 4 — Add target-tracking scaling
Now make the fleet elastic. A target-tracking scaling policy lets you name a metric and a target value, and AWS adds or removes instances to keep the metric near that value — like a thermostat. Average CPU utilization is the most common choice.
aws autoscaling put-scaling-policy \
--auto-scaling-group-name web-app-asg \
--policy-name cpu-at-50 \
--policy-type TargetTrackingScaling \
--target-tracking-configuration '{
"PredefinedMetricSpecification": {"PredefinedMetricType": "ASGAverageCPUUtilization"},
"TargetValue": 50.0
}'
Output:
{
"PolicyARN": "arn:aws:autoscaling:us-east-1:111122223333:scalingPolicy:.../web-app-asg:policyName/cpu-at-50",
"Alarms": [
{"AlarmName": "TargetTracking-web-app-asg-AlarmHigh-..."},
{"AlarmName": "TargetTracking-web-app-asg-AlarmLow-..."}
]
}
For traffic-bound web tiers, ALBRequestCountPerTarget is often a better signal than CPU — it scales on requests per instance rather than a metric that may lag. You can use it with a resource label that points at the ALB and target group.
Verify it end to end
After everything settles (allow a couple of minutes), confirm the ASG instances are healthy in the target group:
aws elbv2 describe-target-health \
--target-group-arn "arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/web-tg/abc123def456" \
--query 'TargetHealthDescriptions[].{Id:Target.Id,State:TargetHealth.State}'
Output:
[
{"Id": "i-0a1b2c3d4e5f6789a", "State": "healthy"},
{"Id": "i-0b2c3d4e5f6789ab", "State": "healthy"}
]
Then hit the ALB’s DNS name in a browser or with curl http://web-alb-1234567890.us-east-1.elb.amazonaws.com and confirm you get a response.
Cost note
You pay for the ALB and the EC2 instances; the ASG is free. An ALB costs roughly $16/month for the fixed hourly charge plus a usage charge (LCUs) that for a small site adds only a few dollars. Two t3.micro On-Demand instances add about $15/month. The whole self-healing tier therefore starts near $31/month, and only the instance portion grows when scaling adds capacity — so a sensible ASG maximum is your main guardrail against a runaway bill.
Best Practices
- Put the ALB in public subnets and the instances in private subnets so only the load balancer is exposed to the internet.
- Let the ASG own target group membership — pass the target group ARN to the ASG and never add scale-out instances by hand.
- Reference the ALB’s security group (not a CIDR) as the source in the instance security group’s inbound rule.
- Set
health-check-type ELBso unhealthy instances are replaced based on real application health, and use a grace period long enough for boot and warm-up. - Span at least two AZs with both the ALB subnets and the ASG subnets so a single data-center failure never takes the tier down.
- Prefer target tracking (CPU or
ALBRequestCountPerTarget) over manual step policies for hands-off elasticity, and cap the maximum size to bound cost.