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.

The keepalive raceALB reuses connidle up to 60 sTarget closeskeepalive at 5 sALB sends requeston closed socketReset, 502app never sees itThe target's keepalive timeout must be longer than the ALB idle timeout.
  1. Intermittent 502s: set the target's keepalive timeout above the ALB idle timeout (for example Gunicorn --keep-alive 75 or Nginx keepalive_timeout 75s with ALB idle 60s).
  2. Constant 502s: check target health, the target port, and whether the target group protocol (HTTP vs HTTPS) matches what the app serves.
  3. Allow the ALB security group to reach the target port in the target's security group.
  4. Read ALB access logs: elb_status_code=502 with target_status_code=- means the connection failed.
  5. 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_code is - and target_processing_time is -1: the ALB could not get a response; connection reset or refused.
  • error_reason such as TargetConnectionErrorResetReceived: 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.

idle connection lifetime (seconds)ALB idle timeout (default)60Gunicorn keep-alive default2: closes first, 502sNginx keepalive_timeout 5s5: closes first, 502sTarget keepalive 75s75: ALB closes first04080Whichever side closes first decides whether the race existsdjango-deployment.com
Set the target keepalive at least 15 seconds above the ALB idle timeout.

3. Constant 502s: check the target group

If every request fails:

  • Target health. In the console or aws elbv2 describe-target-health, are targets unhealthy or draining? 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, not 127.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.

pattern to causePatternCause and fixrare, random, no app errorskeepalive race; targetkeepalive > ALB idleevery requestwrong port, HTTPS targetgroup, or security groupduring deploys onlyno graceful shutdown;draining and SIGTERMon slow endpointsworker killed by timeout or OOMALB access logs show whether the target answered at alldjango-deployment.com
Match the timing of the 502s to the pattern before changing settings.

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

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.

Keep going