Run Django Behind an AWS Application Load Balancer

Configure Django correctly behind an AWS Application Load Balancer: HTTPS listeners with ACM, X-Forwarded-Proto and client IPs, ALLOWED_HOSTS for health checks, idle and keepalive timeouts, stickiness, and deregistration delay for deploys.

Problem statement

An Application Load Balancer (ALB) in front of Django on EC2 or ECS handles TLS, spreads traffic across instances, and removes unhealthy ones. Django has to agree with the ALB about several things for everything to work: which requests are HTTPS, what the client's real IP is, which hosts are allowed (including the ALB's own health checks), and how long idle connections live. Get these wrong and you see redirect loops, 400s on health checks, every request logged from the ALB's IP, or intermittent 502s.

Django behind an ALBALB HTTPS :443ACM certificateTarget groupHTTP to instancesHealth check/health/ pathDjango settingsproxy SSL, hostsTLS ends at the ALB, so Django must trust its forwarded headers and accept its health checks.
  1. Create HTTPS (443) and HTTP (80, redirect to 443) listeners with an ACM certificate.
  2. Use an HTTP target group to Nginx or Gunicorn on the instances, health check path /health/.
  3. In Django: SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https").
  4. Allow health checks: they use the instance IP as Host, so exempt /health/ from host validation or answer it in Nginx.
  5. Get real client IPs from X-Forwarded-For (Nginx real_ip module or Gunicorn forwarded_allow_ips).
  6. Set target keepalive longer than the ALB idle timeout, and a deregistration delay that covers your longest requests.

Step-by-step solution

1. Listeners and certificate

Request a public certificate in ACM for example.com and www.example.com (DNS validation), then create:

  • Listener HTTPS:443 → forward to the target group, using the ACM certificate and a modern TLS policy.
  • Listener HTTP:80 → redirect to HTTPS:443 (status 301).

The ALB terminates TLS; traffic to instances is HTTP inside the VPC.

2. Target group and security groups

Create a target group with protocol HTTP, port 80 (Nginx on the instance) or 8000 (Gunicorn directly), health check path /health/, success codes 200. Security groups: the ALB allows 80 and 443 from the internet; the instances allow the target port only from the ALB's security group. That restriction is what makes forwarded headers trustworthy.

3. Tell Django about HTTPS

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]

The ALB sets X-Forwarded-Proto: https. If Nginx sits on the instance, pass the ALB's header through rather than replacing it with Nginx's $scheme (which is http):

proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
what the ALB adds, what Django does with itValueDjango sideX-Forwarded-Protohttps✓SECURE_PROXY_SSL_HEADERX-Forwarded-Forclient, then proxies✓real_ip or forwarded_allow_ipsHostexample.com✓ALLOWED_HOSTSHealth check Hostinstance private IP!exempt /health/ or answer in NginxOnly trust these headers when instances accept traffic from the ALB alonedjango-deployment.com
Security groups that allow only the ALB make the forwarded headers safe to trust.

4. Pass health checks

ALB health checks send Host: <instance-private-ip>:<port>, which ALLOWED_HOSTS rejects with a 400. Options:

  • Answer /health/ in Nginx: location = /health/ { access_log off; return 200 "ok"; } (checks Nginx only).
  • Exempt the path in a small middleware placed before CommonMiddleware that returns 200 for /health/ after checking the database.
  • Add the private IPs dynamically at startup (fetch from instance metadata) to ALLOWED_HOSTS.

Do not use ALLOWED_HOSTS = ["*"].

5. Real client IPs

With Nginx on the instance:

set_real_ip_from 10.0.0.0/16;     # your VPC CIDR
real_ip_header X-Forwarded-For;
real_ip_recursive on;

With Gunicorn directly, set --forwarded-allow-ips to the VPC range so it trusts the header, and read REMOTE_ADDR in Django. Rate limiting, audit logs, and django-axes all depend on this.

6. Timeouts and deploys

  • Idle timeout on the ALB defaults to 60 seconds. Keep target keepalive longer (Nginx keepalive_timeout 75s) to avoid intermittent 502s.
  • Deregistration delay (default 300 s) is how long the ALB waits for in-flight requests on a deregistering target. Set it just above your longest normal request, for example 30-60 s, so deploys are not slowed needlessly.
  • Stickiness is usually unnecessary for Django with database or Redis sessions; leave it off.
ALB plus Django, end to end1ACM cert, 443 and 80 listeners80 redirects to 4432HTTP target group, /health/instances allow ALB SG only3Django trusts X-Forwarded-Protosecure cookies, CSRF origins4Health checks pass host checksNginx answer or middleware5Keepalive 75 s, dereg delay 60 sno 502s, quick deploysEach step closes one gap between what the ALB knows and what Django seesdjango-deployment.com
Verify each step with the ALB access logs enabled.

Verification check: curl -I http://example.com returns 301 to HTTPS, curl -I https://example.com returns 200, targets show healthy in the console, Django logs show real client IPs, and python manage.py check --deploy passes.

Explanation

An ALB is a layer 7 reverse proxy managed by AWS. Because it terminates TLS and opens new connections to targets, Django loses direct knowledge of the protocol and client address, which the ALB restores through X-Forwarded-* headers. Django deliberately ignores those headers until told which ones to trust, since a client could otherwise forge them. The remaining settings align connection lifetimes and health checking so that the ALB's view of target health matches reality.

Edge cases / notes

  • WebSockets work through ALBs; raise the idle timeout or send pings for long-lived connections.
  • WAF. Attach AWS WAF to the ALB for managed rule sets and rate limiting.
  • Large uploads stream through the ALB; Nginx client_max_body_size on targets still applies.
  • IPv6. Dualstack ALBs accept IPv6 clients; X-Forwarded-For then contains IPv6 addresses.

Next steps

FAQ

Do I need Nginx on the instances behind an ALB?

Not strictly; the ALB can target Gunicorn directly. Nginx still helps with buffering, static files, and request limits.

Why does Django think requests are HTTP behind the ALB?

TLS ends at the ALB. Set SECURE_PROXY_SSL_HEADER so Django trusts X-Forwarded-Proto.

Why do ALB health checks get 400?

They send the instance IP as Host. Answer /health/ in Nginx or exempt it from host validation.

Should I enable sticky sessions?

Usually not. Django sessions in the database or Redis work on any target.

Keep going