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.

Running many app serversLoad balancerhealth checksApp serversstatelessShared statesessions, cacheDatabaseone primaryHorizontal scaling works only once nothing important lives on a single server.

Quick answer

  1. Make servers stateless: nothing a user depends on may live only on one server's disk or memory.
  2. Store sessions in the database, a shared cache (cached_db with Redis), or signed cookies.
  3. Store media in object storage (S3-compatible) or shared storage.
  4. Use a shared cache (Redis or Memcached) for caching, rate limiting, and locks.
  5. Run migrations once per release, outside the web server startup.
  6. Run scheduled jobs and Celery Beat on exactly one host.
  7. Use the same SECRET_KEY and configuration everywhere, give the load balancer a cheap health check, and deploy with rolling updates.
  8. 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 stateSymptom when scaledShared alternative
LocMemCache sessions or cacherandom logouts, stale dataRedis or database
MEDIA_ROOT on local diskmissing uploads on some requestsS3, R2, GCS, or shared filesystem
SQLite databaseseparate databases per serverPostgreSQL or MySQL
files generated at runtime (exports, thumbnails)404 on other serversobject storage
in-process counters, locks, rate limitslimits multiplied by server countRedis
scheduled jobs (cron, Beat)duplicate runssingle scheduler host
logs on diskincomplete investigationcentral logging
move local state out firstMove toSessionsdatabase or Redis sessionsMedia uploads✓object storageCache, rate limitsshared RedisLocksdatabase or Redis locksScheduled jobs!one scheduler, not one per serverAnything stored on one server is invisible to the othersdjango-deployment.com
Local-memory caches are fine only for data that may differ per server.

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
migrations run once, not per serverEvery server runs migrate✕races on the same tables✕locks during start-up✕half-applied surprisesOne release step✓one host or job runs migrate✓servers restart afterwards✓schema suits old and new codeThe same rule applies to collectstatic writing to shared storagedjango-deployment.com
Mixed versions serve traffic during a rolling deploy.

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_HOSTS or HTTPS redirects.
  • Connection draining (deregistration delay): long enough for in-flight requests to finish when a server is removed.
  • Forwarded headers: X-Forwarded-For and X-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.
stateless app servers behind one entry pointLoad balancerTLS, health checks, no stickinessApp servers × Nidentical, statelessPostgreSQLshared, now the bottleneckRedis and object storageshared stateSticky sessions hide state problems until a server diesdjango-deployment.com
Health checks must be answered without a matching Host header.

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:

  1. Run migrations once (backward compatible).
  2. Remove one server from the load balancer (or let health checks fail it while draining).
  3. Deploy and restart the app on that server; wait for health checks to pass.
  4. Repeat for each server.
one server at a timeMigrate oncecompatible schemaDrain server 1remove from LBDeploy and checkhealth passesReturn to LBthen server 2…All updatedrestart workers tooCapacity drops by one server during each stepdjango-deployment.com
Deploy with spare capacity, not at the daily peak.

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_ROOT on local disk.
  • Running migrate in every server's startup script.
  • Enabling cron or Beat on every server from the same provisioning role.

Next steps

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.

Keep going