Scale Django Horizontally Behind a Load Balancer
Prepare Django to run on multiple servers behind a load balancer: shared sessions, media, caches, migrations, scheduled jobs, health checks, and deploys.
Problem statement
A single Django server eventually hits limits: CPU during peak traffic, memory for more workers, or the risk that one machine failure takes the whole site down. Adding a second application server behind a load balancer solves both capacity and redundancy, but only if the application is ready for it.
Code that works perfectly on one server often breaks in subtle ways on two:
- users are logged out randomly because sessions live in local memory
- uploaded files exist only on the server that received them
- caches and rate limits count separately per server
- migrations run twice during deploys
- scheduled jobs run once per server
- a deploy takes all servers down at once
This guide covers what must change for horizontal scaling, in the order problems usually appear, and how to verify each piece.
Quick answer
- Make servers stateless: nothing a user depends on may live only on one server's disk or memory.
- Store sessions in the database, a shared cache (
cached_dbwith Redis), or signed cookies. - Store media in object storage (S3-compatible) or shared storage.
- Use a shared cache (Redis or Memcached) for caching, rate limiting, and locks.
- Run migrations once per release, outside the web server startup.
- Run scheduled jobs and Celery Beat on exactly one host.
- Use the same
SECRET_KEYand configuration everywhere, give the load balancer a cheap health check, and deploy with rolling updates. - Check the database can handle the combined connection and query load.
Step-by-step solution
1. Inventory local state
List everything the app reads or writes locally:
| Local state | Symptom when scaled | Shared alternative |
|---|---|---|
LocMemCache sessions or cache | random logouts, stale data | Redis or database |
MEDIA_ROOT on local disk | missing uploads on some requests | S3, R2, GCS, or shared filesystem |
| SQLite database | separate databases per server | PostgreSQL or MySQL |
| files generated at runtime (exports, thumbnails) | 404 on other servers | object storage |
| in-process counters, locks, rate limits | limits multiplied by server count | Redis |
| scheduled jobs (cron, Beat) | duplicate runs | single scheduler host |
| logs on disk | incomplete investigation | central logging |
Search the codebase for open( writes, MEDIA_ROOT usage, LocMemCache, module-level caches, and background threads.
2. Sessions
The default database session backend already works across servers, because every server reads the same database. Problems start with:
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
Use a shared option:
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.environ["REDIS_URL"],
}
}
Avoid relying on sticky sessions at the load balancer as the fix. They hide the problem until a server is replaced or removed, and they distribute load unevenly.
3. Media files
Uploads written to one server's disk are missing on the others. Move them to object storage with django-storages, which also removes local disk capacity concerns. If you must use a shared filesystem (NFS or a managed file service), test performance and failure behavior; a stalled network filesystem can hang all web workers.
4. Cache, rate limits, and locks
Everything that coordinates between requests must use shared storage:
- the Django cache (
CACHES) for data and cached fragments - throttling in DRF or packages such as django-ratelimit, which use the cache
- locks implemented with
cache.add()
Also check SECRET_KEY and any signing keys: all servers must use the same values, or sessions, password reset links, and signed cookies fail when requests move between servers.
5. Migrations and release tasks
With one server, running migrate in the deploy script is simple. With several, running it on every server during a rolling deploy causes concurrent migration attempts. Run release tasks once:
- a dedicated deploy step from CI or one designated host
- a one-off container or task (Kubernetes Job, ECS
run-task) - a platform release phase
Keep migrations backward compatible, because during a rolling deploy old and new code run against the same schema.
6. Scheduled jobs
Enable cron jobs, systemd timers, and Celery Beat on one host only, or run them on a separate worker tier. Make tasks idempotent so an accidental double run is harmless.
7. The load balancer
Configure:
- Health checks: a cheap endpoint that does not depend on
ALLOWED_HOSTSor HTTPS redirects. - Connection draining (deregistration delay): long enough for in-flight requests to finish when a server is removed.
- Forwarded headers:
X-Forwarded-ForandX-Forwarded-Proto, with Django configured to trust them only from the load balancer. - Timeouts: idle timeout longer than the slowest normal request; WebSockets need longer or heartbeats.
- TLS termination: usually at the load balancer, with HTTP or re-encrypted HTTPS to servers.
A basic Nginx load balancer, if you run your own:
upstream django_app {
least_conn;
server 10.0.1.11:8000 max_fails=3 fail_timeout=10s;
server 10.0.1.12:8000 max_fails=3 fail_timeout=10s;
keepalive 32;
}
server {
listen 443 ssl;
server_name www.example.com;
location / {
proxy_pass http://django_app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_next_upstream error timeout;
}
}
Open source Nginx uses passive health checks (max_fails, fail_timeout); active health checks require Nginx Plus or a different load balancer. proxy_next_upstream error timeout retries idempotent requests on another server when a connection fails; Nginx does not retry non-idempotent requests such as POST by default.
Bind Gunicorn to the private network interface and firewall it so only the load balancer can reach port 8000.
8. Deploys across servers
Deploy with rolling updates so capacity never drops to zero:
- Run migrations once (backward compatible).
- Remove one server from the load balancer (or let health checks fail it while draining).
- Deploy and restart the app on that server; wait for health checks to pass.
- Repeat for each server.
Tools such as Ansible (serial: 1), Kamal, container orchestrators, or cloud deploy services implement this pattern. Keep static assets for the previous release available during the rollout, because pages served by old servers reference old hashed filenames.
9. The database becomes the bottleneck
Adding app servers multiplies database connections and queries. Before scaling out:
- calculate connections: servers × workers × threads plus background workers
- add pooling (PgBouncer or Django's built-in pool) if needed
- watch database CPU, locks, and slow queries under load
- consider read replicas for read-heavy workloads after query optimization
10. Verify
Test with the load balancer in place:
# Requests are spread across servers (add a response header with the hostname in staging)
for i in $(seq 1 20); do curl -sI https://staging.example.com/ | grep -i x-served-by; done | sort | uniq -c
# Stay logged in across many requests
# (log in through a browser, then reload repeatedly and watch the X-Served-By header change)
# Upload a file and fetch it repeatedly
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code}\n" https://staging.example.com/media/uploads/test.png; done
A temporary middleware in staging that sets X-Served-By from socket.gethostname() makes distribution visible.
Verification check:
- sessions persist while requests alternate between servers
- uploads are available regardless of which server handles the request
- stopping one app server does not produce errors beyond in-flight requests
- scheduled jobs run once
- a rolling deploy completes without downtime
Explanation
Horizontal scaling assumes any request can go to any server. That only holds when servers are interchangeable: same code, same configuration, and no user-visible state stored locally. Moving sessions, media, caches, and coordination into shared services achieves that, while release tasks and schedulers move to single, explicit locations. With those in place, adding capacity becomes a matter of adding servers, and the database becomes the next component to plan for.
Edge cases / notes
WebSockets
Long-lived WebSocket connections pin clients to one server for the connection's lifetime. Use a channel layer (Redis) so messages reach clients on any server.
Local caches that are safe
Per-process caches of immutable data (for example parsed configuration loaded at startup) are fine. The problem is per-process copies of data that changes.
Autoscaling
When servers come and go automatically, bake everything into images, keep startup fast, and make sure new instances pass health checks only when fully ready.
Common mistakes
- Using sticky sessions to hide local session storage.
- Leaving
MEDIA_ROOTon local disk. - Running
migratein every server's startup script. - Enabling cron or Beat on every server from the same provisioning role.
Next steps
- Decide how much capacity you need with Size a Server for a Django App: Capacity Planning.
- Get health checks right with Fix Load Balancer Health Checks Failing for Django.
- Fix session problems with Fix Django Login Not Persisting Behind HTTPS.
- Scale reads further with Route Django Reads to PostgreSQL Read Replicas.
FAQ
What do I need to change to run Django on multiple servers?
Store sessions, media, cache data, and locks in shared services, use the same configuration and SECRET_KEY everywhere, run migrations and scheduled jobs once, and put a load balancer with health checks in front.
Do I need sticky sessions for Django behind a load balancer?
No. With database or Redis-backed sessions, any server can handle any request. Sticky sessions only mask local state problems.
How do I handle media uploads with multiple Django servers?
Use object storage such as Amazon S3 or Cloudflare R2 through django-storages, so every server reads and writes the same files.
Where should migrations run when scaling Django horizontally?
Once per release, from CI or a one-off task, before rolling out new code to servers. Keep migrations compatible with the previous release during the rollout.