Auto Scaling groups: Capacity that follows demand
If you've ever watched a production system struggle under unexpected traffic spikes, then scramble to launch more instances by hand, you know the pain of manual scaling. Auto Scaling groups (ASGs) solve this by automatically adjusting your compute capacity based on real demand, so you pay for what you need and never leave users waiting. In this guide, I'll walk you through the mechanics of ASGs—desired capacity, scaling policies, health checks, and instance refresh—and show you exactly how to configure them to keep your applications running smoothly.
Desired, min and max—The ASG keeps the desired count within limits
At the heart of every Auto Scaling group are three numbers: minimum capacity, desired capacity, and maximum capacity. These form the guardrails that prevent your infrastructure from shrinking to nothing or exploding into unaffordable costs.
Minimum capacity is the floor. Even if no one is using your service, ASG will maintain at least this many instances running. For a production API, I typically set this to 2 or 3 to ensure availability during off-peak hours and provide redundancy in case one instance fails.
Desired capacity is the target. ASG tries to keep your instance count at this level by launching or terminating instances as needed. When you create an ASG, you specify this number, and AWS ensures it's always respected (subject to your min and max bounds).
Maximum capacity is the ceiling. If demand spikes badly, ASG will scale up until it reaches this limit, then stop. This prevents a runaway scaling event from bankrupting you—I've seen teams get hit with five-figure bills because they forgot to set a reasonable max.
Here's a practical example. A ride-hailing platform I worked on had an ASG with:
- Min: 5 instances (handle baseline traffic and health checks)
- Desired: 10 instances (comfortable for typical evening rush)
- Max: 50 instances (absolute limit to prevent cost explosion)
When a large event started in the city, request volume doubled. The ASG scaled from 10 to 35 instances within minutes. When the event ended, it scaled back down to 10. The business paid only for what it used.
To update these values on an existing ASG, you can use the AWS CLI:
aws autoscaling update-auto-scaling-group \
--auto-scaling-group-name my-app-asg \
--min-size 2 \
--desired-capacity 5 \
--max-size 20One critical detail: if you set desired capacity outside the min-max range, AWS will clamp it. So if you have min=2, max=10, and you try to set desired=15, it will become 10. Always ensure desired is between min and max.
Scaling policies—Target tracking on CPU or request count is the simplest
Setting min, desired, and max is only half the story. You also need to tell ASG when to scale. This is where scaling policies come in.
There are three types of scaling policies: target tracking, step scaling, and simple scaling. For most modern workloads, target tracking is the way to go because it's simple and effective.
Target tracking scaling means you pick a metric (CPU utilization, request count, memory, or custom metrics) and a target value. ASG then adjusts the desired capacity to keep that metric as close to the target as possible.
For example, on a CRM platform I helped scale, we set up target tracking on average CPU utilization at 70%. When actual CPU crossed above 70%, the ASG would gradually launch new instances. When it dropped below 70%, it would terminate some. The system reached equilibrium without any manual intervention.
Here's how to create a target tracking policy via CloudFormation (a common infrastructure-as-code pattern):
AWSTemplateFormatVersion: '2010-09-09'
Resources:
MyASG:
Type: AWS::AutoScaling::AutoScalingGroup
Properties:
LaunchTemplate:
LaunchTemplateId: lt-12345678
Version: '$Latest'
MinSize: 2
MaxSize: 10
DesiredCapacity: 4
VPCZoneIdentifier:
- subnet-12345
- subnet-67890
TargetGroupARNs:
- arn:aws:elasticloadbalancing:...
ScalingPolicy:
Type: AWS::AutoScaling::ScalingPolicy
Properties:
AutoScalingGroupName: !Ref MyASG
PolicyType: TargetTrackingScaling
TargetTrackingScalingPolicyConfiguration:
TargetValue: 70.0
PredefinedMetricSpecification:
PredefinedMetricType: ASGAverageCPUUtilizationAlternative target metrics include ASGAverageNetworkIn, ASGAverageNetworkOut, and ALBRequestCountPerTarget (request count is often better than CPU for web workloads because it's more directly tied to user activity).
You can also use custom metrics. On a SaaS platform I built, we tracked active database connections as a custom metric and scaled based on that. Target tracking made it trivial to keep connection pools healthy without manual tuning.
One advantage of target tracking: AWS automatically adjusts the scaling speed. If you're far below the target, it scales faster. If you're close, it scales conservatively. This prevents over-correction and cost waste.
Health checks—Use load balancer health checks so unhealthy instances are replaced
Scaling is useless if your new instances are broken. That's where health checks come in. An unhealthy instance doesn't contribute to your capacity, so ASG should terminate it and launch a replacement.
There are two health check types in ASG:
- EC2 health checks (default): Simple status checks run by the hypervisor. They detect if the instance itself is running, but not if your application is actually working.
- ELB health checks (better): If you're using an Application Load Balancer (ALB) or Network Load Balancer (NLB), you can delegate health checking to it. The ALB sends HTTP/HTTPS requests to your instances and marks them unhealthy if they fail.
ELB health checks are almost always the right choice for production because they verify that your application is responding, not just that the EC2 instance is powered on.
To enable ELB health checks on an ASG, you configure the load balancer's target group with a health check path:
aws elbv2 modify-target-group \
--target-group-arn arn:aws:elasticloadbalancing:... \
--health-check-path /health \
--health-check-interval-seconds 30 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 3This tells the ALB to:
- Send GET requests to
/healthevery 30 seconds - Mark an instance healthy after 2 consecutive successful responses
- Mark an instance unhealthy after 3 consecutive failures
On an e-commerce platform I worked on, we had a simple health check endpoint that verified database connectivity:
GET /health HTTP/1.1
// Response if healthy
{
"status": "ok",
"database": "connected",
"timestamp": "2024-01-16T14:32:00Z"
}
// HTTP 200 if all checks pass, 503 if notWhen the database connection pool exhausted (due to a slow query), the health check started failing. The ALB marked those instances unhealthy, ASG terminated them, and launched fresh ones. Requests were never routed to broken instances.
You can also configure ASG to replace unhealthy instances automatically. By default, it terminates them within the unhealthy threshold window. You can adjust the replacement grace period too:
aws autoscaling update-auto-scaling-group \
--auto-scaling-group-name my-app-asg \
--health-check-type ELB \
--health-check-grace-period 300The grace period (300 seconds above) gives new instances time to start up and become healthy before the ALB starts sending traffic to them. This prevents the ALB from health-checking an instance that's still initializing.
Instance refresh—Roll out new launch templates gradually
Let's say you've built a new version of your application and packaged it into a new AMI. You've updated your launch template to use this AMI. Now you need to roll it out to all running instances. Instance refresh is how you do this safely.
Instead of terminating all instances at once (downtime!) or manually replacing them one-by-one (tedious!), instance refresh orchestrates a gradual rollout. It launches new instances with the new template, waits for them to become healthy, then terminates old instances. If anything goes wrong, you can stop or rollback the refresh.
Here's how to start an instance refresh:
aws autoscaling start-instance-refresh \
--auto-scaling-group-name my-app-asg \
--preferences '{
"MinHealthyPercentage": 80,
"InstanceWarmupSeconds": 60,
"CheckpointPercentages": [50],
"CheckpointDelay": 3600
}'The preferences control the rollout behavior:
- MinHealthyPercentage: Keep at least 80% of instances running during the refresh. If you have 10 instances, 8 must stay healthy while the refresh replaces the others. This prevents capacity loss.
- InstanceWarmupSeconds: After launching a new instance, wait 60 seconds before it counts toward MinHealthyPercentage. This gives your app time to initialize.
- CheckpointPercentages: Pause the refresh at 50% completion and wait for your go-ahead before continuing. Useful if you want to monitor metrics before replacing the final instances.
- CheckpointDelay: Wait 3600 seconds (1 hour) at each checkpoint. You can manually complete the refresh sooner if you're confident.
On a trading platform I helped deploy, we used instance refresh to roll out a critical performance fix. We set MinHealthyPercentage to 90 (keep most instances running) and CheckpointPercentages to [50] so we could verify that the new version's latency was acceptable before replacing the rest. The old code kept serving traffic while we validated the new one. Once we confirmed metrics looked good, we resumed and completed the refresh.
You can monitor the refresh progress:
aws autoscaling describe-instance-refreshes \
--auto-scaling-group-name my-app-asg \
--query 'InstanceRefreshes[0].[Status,PercentageComplete,InstancesToTerminate]'If you realize a problem during the refresh, cancel it immediately:
aws autoscaling cancel-instance-refresh \
--auto-scaling-group-name my-app-asgThis stops the refresh and leaves the ASG in a mixed state (old and new instances running). You can then either resume or roll back by updating the launch template back to the old AMI and starting a new refresh.
Common mistakes
Setting max capacity too low. If your maximum is 10 instances but peak demand needs 15, you'll hit the limit and stop scaling. Users get dropped or experience slow response times. Always benchmark peak demand and add headroom.
Ignoring health checks. Using EC2 health checks instead of ELB health checks means broken applications can still receive traffic. Your instances might be running, but returning 500 errors. Always use ELB health checks for application-aware detection.
Scaling on the wrong metric. Scaling on CPU can be misleading. A single slow query might max out CPU on a few instances while most of your fleet idles. For web apps, scale on request count or custom business metrics instead.
Forgetting the warmup period. If your application takes 90 seconds to initialize but you set InstanceWarmupSeconds to 30, new instances get added to the load balancer before they're ready. They'll fail health checks and be terminated. Match warmup to your actual startup time.
Not testing instance refresh. Instance refresh is powerful but can hide deployment problems. Always test a new launch template in a separate, small ASG before rolling it out to production via refresh.
Key takeaways
- Set min, desired, and max capacity to define your scaling bounds. Min ensures availability, desired is your target, max prevents runaway costs.
- Use target tracking scaling policies on request count or CPU to automatically adjust capacity without manual intervention.
- Always use ELB health checks, not EC2 health checks, so ASG terminates broken instances and launches healthy replacements.
- Instance refresh lets you roll out new application versions gradually with zero downtime and built-in rollback if things go wrong.
- Test your scaling behavior under realistic load before trusting it in production. Monitor metrics during peak hours to ensure your policies are working as expected.






Comments
Loading comments…