Fix AWS Application Load Balancer 502s for Django
Fix intermittent and constant 502 Bad Gateway errors from an AWS Application Load Balancer in front of Django: keepalive timeouts shorter than the ALB idle timeout, unhealthy targets, security groups, and HTTP/HTTPS target mismatches.
Problem statement
An Application Load Balancer (ALB) in front of Django on EC2 or ECS returns 502 Bad Gateway with the plain AWS error page. Sometimes it is every request; more often it is a small percentage scattered through the day, which is harder to explain because the app logs show no errors at the same time.
An ALB returns 502 when its connection to the target fails or the target's response is invalid. The classic intermittent cause is keepalive: the ALB reuses idle connections to targets for up to its idle timeout (60 seconds by default), and if Gunicorn or Nginx closes an idle connection sooner, the ALB occasionally sends a request down a connection the target has just closed.
- Intermittent 502s: set the target's keepalive timeout above the ALB idle timeout (for example Gunicorn
--keep-alive 75or Nginxkeepalive_timeout 75swith ALB idle 60s). - Constant 502s: check target health, the target port, and whether the target group protocol (HTTP vs HTTPS) matches what the app serves.
- Allow the ALB security group to reach the target port in the target's security group.
- Read ALB access logs:
elb_status_code=502withtarget_status_code=-means the connection failed. - Make sure workers are not being killed mid-request (timeouts, OOM).
Step-by-step solution
1. Read the ALB access logs
Enable access logs on the load balancer (to S3) and look at 502 entries. The fields target_status_code, target_processing_time, and error_reason explain what happened:
target_status_codeis-andtarget_processing_timeis-1: the ALB could not get a response; connection reset or refused.error_reasonsuch asTargetConnectionErrorResetReceived: the target closed the connection (keepalive race or crash).- A real status from the target: the target returned 502 itself (for example Nginx inside the instance).
CloudWatch metrics HTTPCode_ELB_502_Count versus HTTPCode_Target_5XX_Count separate ALB-generated errors from app errors.
2. Fix keepalive timeouts
The ALB idle timeout defaults to 60 seconds. Gunicorn's default --keep-alive is 2 seconds (for async workers; sync workers do not support keepalive and close after each response), and Nginx's keepalive_timeout is 75 seconds by default but often lowered.
# Gunicorn with gthread or async workers directly behind the ALB
gunicorn config.wsgi:application --worker-class gthread --threads 4 --keep-alive 75
# Nginx on the instance in front of Gunicorn
keepalive_timeout 75s;
The target must keep idle connections open longer than the ALB does, so the ALB is always the side that closes them. Do not raise the ALB idle timeout above the target's keepalive.
3. Constant 502s: check the target group
If every request fails:
- Target health. In the console or
aws elbv2 describe-target-health, are targetsunhealthyordraining? Fix the health check path first; see Fix Django Health Check Failing on a Load Balancer. - Port. The target group port must be where Gunicorn or Nginx listens (for example 8000), and the app must bind
0.0.0.0, not127.0.0.1. - Protocol. A target group set to HTTPS sends TLS to a target that only speaks HTTP, which fails every request. Most setups terminate TLS at the ALB and use HTTP to targets.
- Security groups. The target's security group must allow inbound traffic on the target port from the ALB's security group.
4. Make sure workers survive requests
A Gunicorn worker killed by --timeout or the OOM killer resets its connection, and the ALB returns 502. Check Gunicorn logs for WORKER TIMEOUT and the kernel log for OOM kills, and make the ALB idle timeout and Gunicorn timeout consistent with how long your slowest legitimate requests take.
5. Deploys and draining
During ECS or Auto Scaling deployments, targets are deregistered. Set the target group's deregistration delay (default 300 seconds) and make Gunicorn shut down gracefully on SIGTERM (--graceful-timeout) so in-flight requests complete rather than being reset.
Verification check: compare HTTPCode_ELB_502_Count in CloudWatch over the day before and after the change. For keepalive fixes, the count should drop to zero or near zero under the same traffic.
Explanation
HTTP keepalive lets a client reuse one TCP connection for many requests. When both ends can close an idle connection, there is a short window where one side sends a request just as the other side closes, and the request is lost with a reset. The ALB cannot retry such requests safely, so it returns 502. AWS documents the remedy: configure the target's idle timeout to be longer than the load balancer's, which guarantees the load balancer always closes idle connections first.
Edge cases / notes
- Sync Gunicorn workers close the connection after each response (
Connection: close), so the keepalive race does not apply directly; Nginx in front of them is where to set keepalive. - HTTP/2 to targets (target group protocol version HTTP2 or gRPC) has different behaviour; most Django apps use HTTP/1.1 to targets.
- NLB behaves differently: it passes TCP through and has a 350-second idle timeout.
- Header size. Very large request headers can also produce 502s from the ALB; check
error_reason.
Next steps
- Fix health check problems with Fix Django Health Check Failing on a Load Balancer.
- Review the AWS setup with the Django AWS Production Checklist.
FAQ
Why does the ALB return 502 when Django logs nothing?
The request never reached Django. The connection between the ALB and the target failed, usually from a keepalive mismatch.
What keepalive timeout should the target use?
Longer than the ALB idle timeout, for example 75 seconds when the ALB uses the default 60.
Should the target group use HTTPS?
Only if the target serves TLS. Most Django setups terminate TLS at the ALB and use HTTP to targets.
How do I tell ALB errors from app errors?
Compare CloudWatch HTTPCode_ELB_502_Count with HTTPCode_Target_5XX_Count.