AWS Application Load Balancer
Layer 7 load balancing on AWS. Listener rule precedence, target group health checks, the 502 and 503 errors you will meet, and ALB vs NLB.
An ALB operates at layer 7, so it understands HTTP. That is what lets it route on path, host, header and method, terminate TLS, and check the health of targets with a real HTTP request rather than a TCP handshake.
The structure is always the same: a listener accepts connections on a port, rules on that listener decide where each request goes, and each rule forwards to a target group containing the instances, IPs or Lambda functions that serve it.
Creating one
An ALB needs at least two subnets in different availability zones. This is not optional, and it is the first error people hit:
aws elbv2 create-load-balancer --name web-alb \
--subnets subnet-0a1b2c3d subnet-4e5f6a7b \
--security-groups sg-0123456789abcdef0 \
--scheme internet-facing --type application
aws elbv2 create-target-group --name web-tg \
--protocol HTTP --port 8080 --vpc-id vpc-0abc123 \
--target-type ip \
--health-check-path /healthz \
--health-check-interval-seconds 15 \
--healthy-threshold-count 2 --unhealthy-threshold-count 3
aws elbv2 create-listener --load-balancer-arn "$ALB_ARN" \
--protocol HTTPS --port 443 \
--certificates CertificateArn="$CERT_ARN" \
--default-actions Type=forward,TargetGroupArn="$TG_ARN"
Note the port mismatch: the listener is on 443 and the target group on 8080. The ALB terminates TLS and speaks plain HTTP to your targets, which is the normal arrangement.
Health checks decide everything
A target that fails its health check receives no traffic. That is the whole point, and it is also why a bad health check takes a service down more reliably than a bad deployment.
aws elbv2 describe-target-health --target-group-arn "$TG_ARN" \
--query 'TargetHealthDescriptions[].[Target.Id,TargetHealth.State,TargetHealth.Reason]' \
--output table
------------------------------------------------------
| 10.0.1.42 | unhealthy | Target.ResponseCodeMismatch |
| 10.0.2.17 | healthy | None |
------------------------------------------------------
The Reason field names the cause, and it is the first thing to read:
| Reason | Cause |
|---|---|
Target.ResponseCodeMismatch | Wrong status (often a redirect to HTTPS, or 404) |
Target.Timeout | Health check path too slow, or nothing listening |
Target.FailedHealthChecks | Connection refused; check the security group |
Elb.RegistrationInProgress | Normal, briefly, after registering |
Target.NotInUse | Target not in a subnet the ALB covers |
Two things worth getting right. Give the check a dedicated path that does not touch the database, a /healthz returning 200 unconditionally, because a health check that fails when a dependency is slow removes every target at once and turns a degraded dependency into a total outage. And match the expected codes if your app redirects: a health check hitting / that receives a 301 to HTTPS is marked unhealthy unless you set --matcher HttpCode=200,301.
The deregistration delay also matters during deploys:
aws elbv2 modify-target-group-attributes --target-group-arn "$TG_ARN" \
--attributes Key=deregistration_delay.timeout_seconds,Value=30
The default is 300 seconds, so a rolling deployment waits five minutes per target. Set it slightly above your longest request.
Listener rules are evaluated in priority order
aws elbv2 create-rule --listener-arn "$LISTENER_ARN" --priority 10 \
--conditions '[{"Field":"path-pattern","Values":["/api/*"]}]' \
--actions Type=forward,TargetGroupArn="$API_TG_ARN"
aws elbv2 create-rule --listener-arn "$LISTENER_ARN" --priority 20 \
--conditions '[{"Field":"host-header","Values":["admin.example.com"]}]' \
--actions Type=forward,TargetGroupArn="$ADMIN_TG_ARN"
Lowest priority number wins, and evaluation stops at the first match. A broad rule at priority 5 shadows everything below it. The usual cause of "my new rule is being ignored". List them in order before debugging anything else:
aws elbv2 describe-rules --listener-arn "$LISTENER_ARN" \
--query 'Rules[].[Priority,Conditions[0].Values[0],Actions[0].Type]' --output table
Rules can do more than forward. Redirect and fixed responses avoid a round trip to your servers entirely:
# HTTP → HTTPS on the port 80 listener
--default-actions '[{"Type":"redirect","RedirectConfig":{"Protocol":"HTTPS","Port":"443","StatusCode":"HTTP_301"}}]'
# Block a path at the edge
--actions '[{"Type":"fixed-response","FixedResponseConfig":{"StatusCode":"403","ContentType":"text/plain","MessageBody":"Forbidden"}}]'
Reading the errors
502 Bad Gateway: the ALB reached your target and could not understand the response. Almost always the application: it closed the connection, crashed mid-request, returned malformed headers, or its own keep-alive timeout is shorter than the ALB's 60-second idle timeout, so the target closes a connection the ALB is about to reuse. Setting your server's keep-alive above the ALB idle timeout fixes a surprising number of intermittent 502s.
503 Service Unavailable: no healthy targets. Not an application error at all; go straight to describe-target-health.
504 Gateway Timeout: the target did not respond within the idle timeout. Either the request is genuinely slow or the timeout is too low:
aws elbv2 modify-load-balancer-attributes --load-balancer-arn "$ALB_ARN" \
--attributes Key=idle_timeout.timeout_seconds,Value=120
Connection refused with healthy targets: the target's security group must allow the ALB's security group on the target port. Referencing the ALB's security group as the source, rather than a CIDR, is the correct pattern.
Sticky sessions
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=86400
Stickiness pins a client to one target with a cookie. It works, and it is usually a workaround for state held in memory that should be in ElastiCache or a database. The costs are real: uneven load distribution, and a target's removal dropping sessions. Treat it as a stopgap while making the application stateless.
Client IP and access logs
The ALB terminates the connection, so your application sees the ALB's IP. The real client is in X-Forwarded-For, and X-Forwarded-Proto tells you whether the original request was HTTPS, which matters for generating correct absolute URLs and for redirect loops.
Enable access logs before you need them:
aws elbv2 modify-load-balancer-attributes --load-balancer-arn "$ALB_ARN" \
--attributes Key=access_logs.s3.enabled,Value=true \
Key=access_logs.s3.bucket,Value=my-alb-logs
They record target_processing_time separately from request_processing_time, which is how you establish whether slowness is yours or the balancer's. Query them with Athena.
ALB or NLB
| ALB | NLB | |
|---|---|---|
| Layer | 7 (HTTP) | 4 (TCP/UDP) |
| Routes on | Path, host, header, method | Port only |
| Preserves client IP | No (X-Forwarded-For) | Yes |
| Static IP | No | Yes, one per AZ |
| Non-HTTP protocols | No | Yes |
| WebSockets | Yes | Yes |
Use an ALB for HTTP and HTTPS. Use an NLB for other protocols, when you need a static IP, or for extreme throughput with minimal latency.
Quick reference
| Task | Command |
|---|---|
| Create an ALB | aws elbv2 create-load-balancer --type application |
| Create a target group | aws elbv2 create-target-group --health-check-path /healthz |
| Check target health | aws elbv2 describe-target-health --target-group-arn "$TG" |
| List rules in order | aws elbv2 describe-rules --listener-arn "$L" |
| Register a target | aws elbv2 register-targets --targets Id=… |
| Change idle timeout | --attributes Key=idle_timeout.timeout_seconds,Value=120 |
| Shorten drain time | Key=deregistration_delay.timeout_seconds,Value=30 |
| Enable access logs | Key=access_logs.s3.enabled,Value=true |
Key takeaways
- An ALB needs subnets in at least two availability zones.
describe-target-healthand itsReasonfield is the first command for any ALB problem.- Health checks must not depend on the database, or a slow dependency becomes a full outage.
- Rules evaluate lowest priority first and stop at the first match: a broad rule shadows the rest.
- 503 means no healthy targets; 502 usually means your application closed the connection.
- Set server keep-alive above the ALB's 60-second idle timeout to stop intermittent 502s.
- Lower the 300-second deregistration delay or every deploy crawls.
- The client IP is in
X-Forwarded-For, and the ALB's IP is what your app sees.
Check your understanding
Every request returns 503
An ALB returns 503 Service Unavailable for all requests. The application runs fine when tested directly on the instances. Where should you look?
A rule that is never reached
A new rule at priority 50 routes /api/v2/* to a new target group, but those requests keep going to the old one. An existing rule at priority 10 matches /api/*. What is happening?
Frequently Asked Questions
What is the difference between an ALB and an NLB?
An ALB works at layer 7, so it parses HTTP and can route on path, host header, HTTP header and method, terminate TLS, and perform HTTP health checks. An NLB works at layer 4, forwarding TCP and UDP without inspecting content, which makes it faster, gives it a static IP per availability zone, lets it carry non-HTTP protocols, and means it preserves the client's source IP. Use an ALB for web traffic, and an NLB when you need a static IP, a non-HTTP protocol, or extreme throughput.
Why is my ALB returning 502 Bad Gateway?
The ALB reached your target and could not use the response, so the cause is nearly always on the target side: the connection was closed early, the application crashed mid-request, or the response headers were malformed. The subtle and common case is a keep-alive mismatch, if your server's keep-alive timeout is shorter than the ALB's 60-second idle timeout, the target closes a connection the ALB is about to reuse, producing intermittent 502s under no particular load. Set the server's keep-alive above the ALB's idle timeout.
Why is my ALB target unhealthy when the app works fine?
Read the Reason in describe-target-health. Target.ResponseCodeMismatch usually means the health check path returns a redirect to HTTPS or a 404. Fix the path or widen --matcher to accept it. Target.FailedHealthChecks typically means the target's security group does not allow the ALB's security group on the target port. Target.Timeout means nothing is listening or the path is too slow. Testing the app from your laptop does not exercise any of those.
What should an ALB health check endpoint do?
Return 200 quickly and check nothing external. A health check that queries the database seems more thorough but is actively dangerous: when the database slows, every target fails simultaneously and the ALB removes all of them, converting a degraded dependency into a complete outage. Use a /healthz that confirms the process is serving requests, and monitor dependencies separately with real alarms. If you need a dependency-aware check, keep it on a different path used for readiness gating rather than for load balancer membership.
How do I get the real client IP behind an ALB?
Read the X-Forwarded-For header. The ALB terminates the connection, so the source IP your application sees is the load balancer's. Take the value your ALB appended rather than trusting the whole chain, since clients can send their own. X-Forwarded-Proto is equally important: it tells you whether the original request was HTTPS, which matters for building absolute URLs and for avoiding redirect loops when your app decides it needs to force HTTPS.
Why are my deployments slow behind an ALB?
The deregistration delay, which defaults to 300 seconds. When a target is deregistered the ALB stops sending it new requests but waits that long for in-flight ones to complete before considering it drained, so a rolling deployment pauses five minutes per target. Set deregistration_delay.timeout_seconds to a little above your longest expected request, commonly 30 seconds, and rolling deployments get dramatically faster without cutting anything off mid-request.