A load balancer sits in front of your EC2 instances and spreads incoming requests across them, so no single server is overwhelmed and your app keeps serving even when an instance fails. On AWS this is Elastic Load Balancing (ELB). This guide walks through creating an Application Load Balancer (ALB) in front of EC2 instances and then testing it - proving that traffic is actually distributed, that health checks work, and that a failed instance is drained without dropping requests.
If you came here for a Classic Load Balancer tutorial: the Classic Load Balancer (CLB) is the previous generation, and AWS now steers all new work to the Elastic Load Balancing v2 family (ALB, NLB, GWLB). We use the modern stack throughout. Once your load balancer is verified, the natural next step is to put an Auto Scaling Group behind it so capacity follows demand.
Which load balancer type should you use?
AWS offers four load balancer types. Pick by the layer you need to route at and the protocol your app speaks:
- Application Load Balancer (ALB) - Layer 7 (HTTP/HTTPS). Host- and path-based routing, redirects, WebSockets, gRPC, built-in authentication, and per-request routing to target groups. This is the default for web apps and APIs and what we use in this guide.
- Network Load Balancer (NLB) - Layer 4 (TCP/UDP/TLS). Ultra-low latency, millions of requests per second, static IPs with the option of an Elastic IP per AZ. Reach for it for non-HTTP protocols, extreme throughput, or when clients need a fixed IP to allow-list.
- Gateway Load Balancer (GWLB) - Layer 3/4. Inserts inline third-party network appliances (firewalls, IDS/IPS) transparently into the traffic path. Niche; most app teams never need it.
- Classic Load Balancer (CLB) - legacy, previous generation. Do not pick it for new work; migrate existing CLBs to an ALB or NLB.
| Application LB (ALB) | Network LB (NLB) | Classic LB (CLB) | |
|---|---|---|---|
| OSI layer | 7 (HTTP/HTTPS) | 4 (TCP/UDP/TLS) | 4 & 7 (legacy) |
| Protocols | HTTP, HTTPS, gRPC, WS | TCP, UDP, TLS | HTTP, HTTPS, TCP |
| Routing | Host / path / header / method | Flow hash (5-tuple) | Basic round-robin |
| Static IP | No (use DNS name) | Yes (one per AZ) | No |
| Targets | Instance, IP, Lambda | Instance, IP, ALB | Instance only |
| Best for | Web apps & APIs | TCP / high throughput / static IP | Nothing new - migrate off |
What you'll build
Route 53 / DNS
|
Application Load Balancer (public subnets, 2 AZs)
listeners: 80 -> redirect 443
443 -> target group
|
Target Group (health check: HTTP /healthz)
/ \
EC2 web-1 EC2 web-2
(AZ-a, private) (AZ-b, private)
The load balancer is regional and spans at least two Availability Zones. Instances register into a target group, and a listener decides which target group receives a request. Health checks continuously probe each target, and the ALB only sends traffic to targets that pass. Keep instances stateless (sessions in ElastiCache/DynamoDB, uploads in S3) so any instance can serve any request and you can add or remove instances freely.
Step 0 - Launch two EC2 instances you can tell apart
To prove traffic is balanced you need to know which instance answered. Launch two instances in two different AZs, install a tiny web app, and have each return its own hostname. We'll use Nginx for the demo:
# Run on each EC2 instance
sudo apt-get update && sudo apt-get install -y nginx # Ubuntu
# sudo dnf install -y nginx # Amazon Linux 2023
# Make each instance return its own identity + a health endpoint.
# IMDSv2: get a token first, then read metadata.
TOKEN=$(curl -s -X PUT http://169.254.169.254/latest/api/token \
-H "X-aws-ec2-metadata-token-ttl-seconds: 60")
IID=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/instance-id)
echo "Served by $IID" | sudo tee /var/www/html/index.html
echo "ok" | sudo tee /var/www/html/healthz # health-check target
sudo systemctl enable --now nginxStep 1 - Set up security groups
Use two security groups, chained so the world only ever talks to the load balancer:
- ALB SG - inbound
80and443from0.0.0.0/0(and::/0). - Instance SG - inbound app port (
80) only from the ALB SG (reference the SG ID, not a CIDR). This stops anyone hitting the instances directly and bypassing the balancer.
# Allow the ALB security group to reach the instances on port 80
aws ec2 authorize-security-group-ingress \
--group-id sg-instances \
--protocol tcp --port 80 \
--source-group sg-alb
Step 2 - Create a target group and tune health checks
A target group is the pool the listener forwards to. Targets can be:
- instance - register by EC2 instance ID (what we use here);
- ip - register raw IPs (containers, Fargate, or on-prem over Direct Connect/VPN);
- lambda - invoke a Lambda function per request.
The health check is what makes a load balancer trustworthy. The key settings:
- Path - e.g.
/healthz. Point it at an endpoint that checks the app and its critical dependencies, not a static file. - Healthy threshold - consecutive passes before a target receives traffic (default 5; we use 2 for faster recovery).
- Unhealthy threshold - consecutive failures before a target is pulled (default 2).
- Interval / timeout - how often to probe, and how long to wait for a response.
- Matcher - the HTTP codes that count as healthy (e.g.
200).
# Create the target group with an app-level health check
aws elbv2 create-target-group \
--name web-tg \
--protocol HTTP --port 80 \
--vpc-id vpc-0abc123 \
--target-type instance \
--health-check-protocol HTTP \
--health-check-path /healthz \
--matcher HttpCode=200 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 2 \
--health-check-interval-seconds 15 \
--health-check-timeout-seconds 5
# Register both instances (note: across two AZs)
aws elbv2 register-targets \
--target-group-arn <TG_ARN> \
--targets Id=i-0web1aaaaaa Id=i-0web2bbbbbbStep 3 - Create the ALB and an HTTP listener
Create an internet-facing ALB across the public subnets of (at least) two AZs, attach the ALB security group, then add a listener. A listener watches a port/protocol and uses rules to route to target groups; rules can match host, path, header, query string, or HTTP method, with a default action as the fallback.
# Internet-facing ALB across two AZs' public subnets
aws elbv2 create-load-balancer \
--name web-alb \
--type application \
--scheme internet-facing \
--subnets subnet-0aaa subnet-0bbb \
--security-groups sg-alb
# Plain HTTP listener (we add HTTPS + redirect next)
aws elbv2 create-listener \
--load-balancer-arn <ALB_ARN> \
--protocol HTTP --port 80 \
--default-actions Type=forward,TargetGroupArn=<TG_ARN>
Step 4 - Add HTTPS, redirect, cross-zone and stickiness
For anything real, terminate TLS on the ALB. Request (or import) a certificate in AWS Certificate Manager (ACM) in the same region as the ALB, then:
- add an HTTPS listener on 443 that references the ACM cert and a modern security policy;
- change the port-80 listener to redirect to 443 (HTTP 301) so no plaintext traffic is served;
- decide on cross-zone load balancing - on an ALB it is on by default and free (every node can reach targets in every AZ); on an NLB it is off by default;
- enable sticky sessions only if your app keeps server-side session state. ALB stickiness uses a cookie (
AWSALB, or an app-defined cookie). Prefer stateless instances and skip stickiness where you can.
# HTTPS listener with an ACM certificate
aws elbv2 create-listener \
--load-balancer-arn <ALB_ARN> \
--protocol HTTPS --port 443 \
--certificates CertificateArn=<ACM_CERT_ARN> \
--ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
--default-actions Type=forward,TargetGroupArn=<TG_ARN>
# Turn the port-80 listener into an HTTP->HTTPS 301 redirect
aws elbv2 modify-listener \
--listener-arn <HTTP_LISTENER_ARN> \
--default-actions '[{"Type":"redirect","RedirectConfig":{"Protocol":"HTTPS","Port":"443","StatusCode":"HTTP_301"}}]'
# Optional: enable cookie stickiness on the target group (1 hour)
aws elbv2 modify-target-group-attributes \
--target-group-arn <TG_ARN> \
--attributes Key=stickiness.enabled,Value=true \
Key=stickiness.type,Value=lb_cookie \
Key=stickiness.lb_cookie.duration_seconds,Value=3600
Testing the load balancer
Creating the ALB is only half the job; the other half is proving it works. An ALB has no static IP - it's reached by its DNS name (find it in the console or via the CLI). First, confirm that name resolves:
# Get the ALB's DNS name, then resolve it
aws elbv2 describe-load-balancers --names web-alb \
--query 'LoadBalancers[0].DNSName' --output text
# -> web-alb-1234567890.eu-west-2.elb.amazonaws.com
dig +short web-alb-1234567890.eu-west-2.elb.amazonaws.com
You'll usually get two or more A records - one per AZ node. That set can change as AWS scales the balancer, which is exactly why you point Route 53 at it with an alias record, never a hard-coded IP.
Confirm traffic is actually distributed
Because each instance returns its own ID, a quick loop shows the spread. Hit the endpoint many times and count who answered:
for i in $(seq 1 30); do
curl -s https://app.example.com/ ;
done | sort | uniq -c
# 16 Served by i-0web1aaaaaa
# 14 Served by i-0web2bbbbbb
A roughly even split confirms balancing. If one instance never appears, it's failing its health check or stickiness is pinning you to one target. You can also confirm from the other side by counting hits in each instance's access log:
wc -l /var/log/nginx/access.log # run on each instance
Check target-group health
Open EC2 -> Target Groups -> your group -> Targets and every instance should read healthy. From the CLI:
aws elbv2 describe-target-health \
--target-group-arn <TG_ARN> \
--query 'TargetHealthDescriptions[].{id:Target.Id,state:TargetHealth.State,reason:TargetHealth.Reason}'
States you'll see: initial (registering, first checks running), healthy, unhealthy, draining (deregistering, finishing in-flight requests), and unused (no traffic - e.g. an AZ not enabled). If a target is stuck unhealthy, the Reason (such as Target.Timeout or Target.ResponseCodeMismatch) tells you whether it's a network/SG problem or the app returning the wrong status.
Simulate a failure and watch it drain
This is the test most people skip. Take one instance out and confirm the ALB stops sending it traffic and that in-flight requests aren't dropped:
# On web-1, break the app (or the health endpoint)
sudo systemctl stop nginx
Within unhealthy-threshold x interval seconds the target flips to unhealthy in describe-target-health, and your distribution loop now shows only web-2 answering - with no client-visible errors, because the ALB simply stopped routing to the bad target.
When you intentionally remove a healthy instance, deregister it rather than killing it. The target enters draining and the ALB keeps existing connections alive until they finish, up to the deregistration delay (a.k.a. connection draining, default 300s, tuned via a target-group attribute):
aws elbv2 modify-target-group-attributes \
--target-group-arn <TG_ARN> \
--attributes Key=deregistration_delay.timeout_seconds,Value=30
aws elbv2 deregister-targets \
--target-group-arn <TG_ARN> \
--targets Id=i-0web1aaaaaa
Bring the instance back (systemctl start nginx, then re-register) and watch it return to healthy once the healthy threshold passes.
Run a light load test
Finally, generate concurrent traffic and watch the balancer hold up. hey and k6 are the modern choices; Apache Bench (ab) still works for a quick check:
# hey: 5,000 requests, 50 concurrent
hey -n 5000 -c 50 https://app.example.com/
# or Apache Bench
ab -n 5000 -c 50 https://app.example.com/
# or k6 for scripted, staged load
k6 run --vus 50 --duration 30s load.js
While it runs, watch the CloudWatch metrics on the ALB: RequestCount, TargetResponseTime, HTTPCode_Target_2XX_Count, and especially HTTPCode_ELB_5XX_Count (balancer-side errors) versus HTTPCode_Target_5XX_Count (app errors). A clean run spreads requests evenly across healthy targets with no ELB 5XXs - the signal that you're ready to add auto scaling.
Common problems and how to read them
- 502 Bad Gateway - the ALB reached the target but got an invalid/empty response: app crashed, wrong port, or it closed the keep-alive early. Check the app logs and that the target-group port matches the port your app serves on.
- 503 Service Unavailable - no healthy targets in the group. Almost always a failing health check or an empty/de-registered target group.
- 504 Gateway Timeout - the target didn't respond within the idle timeout; a slow app, or a security group blocking the health-check/data port.
- Targets never go healthy - the instance SG isn't allowing the ALB SG on the health-check port, the health path returns a non-matching code, or the app isn't listening on
0.0.0.0. - Uneven distribution - sticky sessions, long-lived keep-alive connections, or only one AZ enabled. Enable a second AZ and confirm cross-zone balancing is on.
Next step: make it elastic with Auto Scaling
A verified load balancer gives you high availability today. To also get elasticity - capacity that grows and shrinks with demand and replaces failed instances automatically - put an EC2 Auto Scaling Group behind the same target group. We cover launch templates, target-tracking policies and instance refresh end-to-end in AWS Auto Scaling with Auto Scaling Groups & Load Balancers.
Related reading: lock down what your instances can do with IAM roles and policies, and keep access recoverable with how to get into an EC2 instance after losing the PEM file.
MicroPyramid has spent 12+ years building and operating AWS workloads across 50+ projects for startups and enterprises. If you'd rather hand it off, our AWS consulting services and cloud migration services cover load balancing, auto scaling, and production-grade VPC design.
Frequently Asked Questions
Should I use an ALB or an NLB?
Use an ALB for almost all HTTP/HTTPS web apps and APIs - it gives you path/host routing, redirects, TLS termination, WebSockets and authentication at Layer 7. Choose an NLB when you need Layer-4 TCP/UDP, extreme throughput at the lowest latency, a static IP clients can allow-list, or you're balancing a non-HTTP protocol. Some architectures use both: an NLB for ingress with static IPs, forwarding to an ALB for routing.
Is the Classic Load Balancer still recommended?
No. The Classic Load Balancer is the previous generation, and AWS directs all new work to the ELB v2 family (ALB/NLB/GWLB). It lacks target groups, host/path routing and most modern features. For new builds use an ALB or NLB, and plan to migrate existing CLBs - AWS provides a migration wizard.
How do load balancer health checks work?
The load balancer probes each target on a path and port at a set interval. A target becomes healthy after a number of consecutive successes (the healthy threshold) and unhealthy after a number of consecutive failures (the unhealthy threshold). Only healthy targets receive traffic. Point the check at an endpoint that verifies the app and its critical dependencies, and make sure the matcher code (e.g. 200) matches what that endpoint returns.
How do I test that traffic is actually balanced?
Have each instance return something identifying (its instance ID), then send many requests through the ALB's DNS name in a loop and count responses: for i in $(seq 1 30); do curl -s https://app.example.com/; done | sort | uniq -c. A roughly even split confirms distribution. Cross-check with aws elbv2 describe-target-health (all targets healthy) and per-instance access-log counts. If one target never appears, suspect a failed health check or sticky sessions.
How do I add HTTPS to the load balancer?
Request a certificate in AWS Certificate Manager (ACM) in the same region as the ALB, add an HTTPS listener on port 443 that references the cert with a modern ssl-policy, and change the port-80 listener to a 301 redirect to 443. TLS is terminated at the ALB, so instances can speak plain HTTP inside the VPC. ACM auto-renews the certificate.
Do my instances need to be in multiple Availability Zones?
For real high availability, yes. An ALB requires subnets in at least two AZs, and you should register healthy targets in each. If an entire AZ degrades, the load balancer keeps serving from the other; with everything in one AZ, that AZ is a single point of failure. Cross-zone load balancing (on by default for ALB) then spreads requests evenly across all healthy targets regardless of AZ.