Load Balance Django With HAProxy
Put HAProxy in front of several Django app servers: active health checks on a /health/ endpoint, TLS termination, X-Forwarded-Proto, connection limits per server, rolling deploys with drain, and stick tables for basic rate limiting.
Problem statement
Once Django runs on two or more app servers, something must spread traffic across them, notice when one fails, and let you take servers out for deploys without errors. Cloud load balancers do this for a fee; on your own servers, HAProxy is the standard open-source choice. It offers active health checks (which open-source Nginx lacks), detailed stats, per-server connection limits, and runtime control for draining servers during rolling deploys.
- Install HAProxy on a dedicated host (or pair with keepalived for redundancy).
- Define a
frontendthat terminates TLS and setsX-Forwarded-Proto, and abackendwith each app server. - Add active health checks:
option httpchkwithhttp-check send meth GET uri /health/ hdr Host example.com. - Limit concurrency per server with
maxconnclose to each server's worker capacity. - For deploys, set a server to
drainvia the runtime API, deploy, then set it back toready. - Trust
X-Forwarded-ForandX-Forwarded-Protofrom HAProxy only, in Nginx or Django.
Step-by-step solution
1. Basic configuration
# /etc/haproxy/haproxy.cfg
global
log /dev/log local0
maxconn 4096
stats socket /run/haproxy/admin.sock mode 660 level admin
defaults
mode http
log global
option httplog
option forwardfor
timeout connect 5s
timeout client 60s
timeout server 60s
frontend https_in
bind :80
bind :443 ssl crt /etc/haproxy/certs/example.com.pem alpn h2,http/1.1
http-request redirect scheme https unless { ssl_fc }
http-request set-header X-Forwarded-Proto https if { ssl_fc }
default_backend django
backend django
balance leastconn
option httpchk
http-check send meth GET uri /health/ ver HTTP/1.1 hdr Host example.com
http-check expect status 200
default-server inter 3s fall 3 rise 2 maxconn 40
server app1 10.0.0.11:80 check
server app2 10.0.0.12:80 check
leastconn suits Django well because request durations vary. option forwardfor adds X-Forwarded-For.
2. Health checks Django can pass
The check sends Host: example.com, so it passes ALLOWED_HOSTS, and uses plain HTTP to the backend, so exempt /health/ from SECURE_SSL_REDIRECT or rely on Nginx on each app server answering it. A server is marked down after 3 failures (fall 3) and up after 2 successes (rise 2), checked every 3 seconds.
3. Concurrency limits
maxconn 40 on each server makes HAProxy queue requests at the load balancer instead of overloading a backend. Set it near the backend's real capacity: Gunicorn workers times threads, plus a little headroom. Queued requests wait up to timeout queue (defaults to timeout connect); set it explicitly, for example timeout queue 30s.
4. Rolling deploys with drain
echo "set server django/app1 state drain" | sudo socat stdio /run/haproxy/admin.sock
# wait for active sessions to reach zero
echo "show servers state django" | sudo socat stdio /run/haproxy/admin.sock
# deploy app1, run checks
echo "set server django/app1 state ready" | sudo socat stdio /run/haproxy/admin.sock
drain stops new connections while existing ones finish. Script this per server in your deploy tool so only one server is out at a time.
5. Forwarded headers in Django
If HAProxy talks directly to Gunicorn, configure Django:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
and restrict the app servers' firewall so only HAProxy can reach them, which keeps those headers trustworthy. If Nginx runs on each app server, use set_real_ip_from <haproxy-ip>; and real_ip_header X-Forwarded-For; there, and pass X-Forwarded-Proto through instead of overwriting it with $scheme.
6. Basic rate limiting
frontend https_in
stick-table type ip size 100k expire 60s store http_req_rate(10s)
http-request track-sc0 src
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }
This rejects clients making more than 100 requests in 10 seconds.
Verification check: the stats page or show stat shows both servers UP; stopping Gunicorn on one server marks it DOWN within about 10 seconds while the site stays up; a drained deploy produces no 5xx responses in HAProxy logs.
Explanation
HAProxy is a high-performance TCP and HTTP load balancer. Unlike passive failure detection, its active checks probe each backend on a schedule and change server state independently of user traffic, so a broken server is removed before users hit it. Its queueing model (maxconn per server with a global queue) protects Django servers from overload by holding excess requests at the balancer, and the runtime API makes controlled deploys possible without editing configuration.
Edge cases / notes
- WebSockets work through HAProxy in HTTP mode; set
timeout tunnel 1hfor long-lived connections. - Sticky sessions are rarely needed for Django when sessions live in the database or Redis; avoid them unless required.
- Certificates. HAProxy expects certificate and key in one PEM file; reload after renewals with a Certbot deploy hook.
- HTTP/2 to clients is supported with
alpn h2,http/1.1; backends stay HTTP/1.1.
Next steps
- Plan the whole multi-server setup with Scale Django Horizontally Behind a Load Balancer.
- Build the endpoint HAProxy checks with Django Health Check Endpoint Guide.
FAQ
HAProxy or Nginx for load balancing Django?
Both work. HAProxy has active health checks and runtime control in the open-source version; Nginx needs NGINX Plus for active checks.
How does HAProxy know a Django server is healthy?
Through active HTTP checks to a health endpoint, marking servers down after consecutive failures.
How do I deploy without downtime behind HAProxy?
Drain one server through the runtime API, deploy it, set it ready, then repeat for the next.
Do I need sticky sessions?
Usually not. Store sessions in the database or Redis so any server can handle any request.