Deploy Django with Granian

Run Django in production with Granian, the Rust HTTP server for Python: WSGI and ASGI interfaces, workers and threads, a systemd unit, Nginx in front, and when to choose it.

Problem statement

Gunicorn has been the default Django application server for over a decade, and Uvicorn (often under Gunicorn) is the usual choice for ASGI. Granian is a newer server written in Rust that speaks WSGI, ASGI, and its own RSGI interface from a single binary. It handles HTTP parsing and connection management in Rust, supports HTTP/2, and does not need a separate process manager for its workers.

Switching servers is easy to do badly: picking the wrong interface, copying Gunicorn worker counts that do not translate, or losing features you relied on, such as graceful reloads or request timeouts. This guide deploys Django with Granian correctly and explains when it is worth choosing.

Django with GranianNginxTLS, static filesGranianRust HTTP layerWorkersprocesses x threadsDjangoWSGI or ASGI appGranian replaces Gunicorn or Uvicorn; Nginx, systemd, and Django settings stay the same.
  1. pip install granian into the app's virtual environment.
  2. For a synchronous Django app, run granian --interface wsgi config.wsgi:application.
  3. For an async app, run granian --interface asgi config.asgi:application.
  4. Set --workers (processes) and, for WSGI, --blocking-threads (threads per worker) based on load tests.
  5. Bind to 127.0.0.1:8000 and keep Nginx in front for TLS, static files, buffering, and limits.
  6. Run it under systemd with Restart=on-failure, and load test before switching production traffic.

Step-by-step solution

1. Install

/srv/myapp/venv/bin/pip install granian
/srv/myapp/venv/bin/granian --version
/srv/myapp/venv/bin/granian --help

Granian ships prebuilt wheels for common Linux platforms. Options have evolved between releases, so check --help for the version you installed and pin it in your requirements file.

2. Choose the interface

Your Django appInterfaceTarget
Synchronous views, standard ORM usagewsgiconfig.wsgi:application
Async views, Channels-free streaming, long-lived responsesasgiconfig.asgi:application

WSGI is the safer default. Django's ORM is synchronous, and under ASGI every synchronous call is run in a thread through sync_to_async, which adds overhead for an app that is mostly synchronous. Choose ASGI when you have genuinely async code paths.

WSGI or ASGI?Does the app rely on asyncviews or long-lived responses?no: typical Django--interface wsgiconfig.wsgi:applicationyes--interface asgiconfig.asgi:applicationWSGI avoids wrapping every ORM call in sync_to_asyncdjango-deployment.com
Switching later is a flag and a target change, not a code change.

3. Run it manually first

cd /srv/myapp/app
set -a; . /etc/myapp.env; set +a
/srv/myapp/venv/bin/granian --interface wsgi \
  --host 127.0.0.1 --port 8000 \
  --workers 2 --blocking-threads 4 \
  config.wsgi:application

In another shell:

curl -sI -H "Host: example.com" http://127.0.0.1:8000/ | head -1

4. Size workers and threads

  • --workers: number of processes. Each holds a full copy of Django, so memory scales with this number. Start with the number of CPU cores.
  • --blocking-threads: for WSGI, the number of threads per worker that run Django code. Views that wait on the database or external APIs benefit from a few threads; CPU-bound views do not.

Gunicorn's classic 2 x cores + 1 rule was designed for single-threaded sync workers. With Granian, start with workers = cores and a small thread count, then load test with realistic traffic and watch latency percentiles, CPU, and memory.

processes for CPU, threads for waiting--workersseparate processeseach holds a full copy of Django✓start at the number of CPU cores--blocking-threadsthreads inside each worker✓help views that wait on I/O✕no help for CPU-bound viewsGunicorn’s 2 × cores + 1 rule does not apply heredjango-deployment.com
Load test with real traffic and watch memory before raising either number.

5. Create a systemd unit

# /etc/systemd/system/myapp.service
[Unit]
Description=myapp (Granian)
After=network.target

[Service]
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp/app
EnvironmentFile=/etc/myapp.env
ExecStart=/srv/myapp/venv/bin/granian --interface wsgi \
  --host 127.0.0.1 --port 8000 \
  --workers 2 --blocking-threads 4 \
  --respawn-failed-workers \
  --access-log \
  config.wsgi:application
Restart=on-failure
KillSignal=SIGINT
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
journalctl -u myapp -f

--respawn-failed-workers restarts a worker process that crashes instead of leaving the server with fewer workers. Granian also offers options to recycle workers after a lifetime, which can contain slow memory growth in the same way as Gunicorn's --max-requests; see --help for the names in your version.

6. Keep Nginx in front

upstream myapp {
    server 127.0.0.1:8000;
    keepalive 16;
}

server {
    listen 443 ssl;
    server_name example.com;
    # ssl_certificate lines from Certbot

    client_max_body_size 20m;

    location /static/ {
        alias /srv/myapp/app/staticfiles/;
        expires 30d;
    }

    location / {
        proxy_pass http://myapp;
        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_read_timeout 60s;
    }
}
Granian replaces Gunicorn, not NginxNginxTLS, static files, bufferingGranian on 127.0.0.1:8000workers and threadsDjangoWSGI or ASGI applicationSlow clients are absorbed by Nginx, not by Granian workersdjango-deployment.com
The Nginx config is the same one you would use in front of Gunicorn.

Nginx still does useful work Granian does not replace: TLS with Certbot renewal, efficient static file serving, request body limits, buffering slow clients, and rate limiting. Upstream keepalive avoids opening a new TCP connection per request.

Granian does not enforce a per-request timeout the way Gunicorn's --timeout kills stuck workers. Use Nginx's proxy_read_timeout, database statement_timeout, and client timeouts on outbound HTTP calls so a slow request cannot hold a thread forever.

7. Deploy updates

cd /srv/myapp/app && git pull
/srv/myapp/venv/bin/pip install -r requirements.txt
/srv/myapp/venv/bin/python manage.py migrate --noinput
/srv/myapp/venv/bin/python manage.py collectstatic --noinput
sudo systemctl restart myapp

A restart briefly refuses connections while the new process starts. Nginx can retry on another upstream if you run two instances on different ports and restart them one at a time.

Verification check:

systemctl is-active myapp
curl -sI https://example.com/ | head -1
ps -o pid,rss,cmd -u myapp | grep granian

Compare memory per worker and latency with your previous server under the same load test.

Explanation

Granian runs the network layer (accepting connections, parsing HTTP, managing keep-alive and HTTP/2) in Rust threads and hands complete requests to Python workers through the interface you select. For WSGI, each worker runs your Django application in a pool of blocking threads. The operational model is the same as Gunicorn's: a supervisor process manages workers, systemd manages the supervisor, and Nginx faces the internet. What changes is throughput per worker, memory profile, and the available options.

Edge cases / notes

When to stay on Gunicorn

Gunicorn has a larger ecosystem of documentation, integrations, and operational knowledge, plus features such as --timeout worker killing and graceful HUP reloads. If your bottleneck is the database or external APIs, changing the application server rarely matters. Switch to Granian when load tests show a real gain for your workload.

WebSockets

Granian's ASGI interface supports WebSockets. For Django Channels, run the Channels ASGI application under the asgi interface and configure Nginx to pass Upgrade headers.

Rollback

Keep the Gunicorn unit file in the repository. Rolling back is swapping ExecStart, daemon-reload, and restart.

When to automate this

Put the server command and its options in one place (a systemd template or container CMD) managed by your deploy tooling, so switching servers or changing worker counts is a reviewed change rather than an edit on the server.

Next steps

FAQ

Is Granian faster than Gunicorn for Django?

Often in synthetic benchmarks, because HTTP handling runs in Rust. For typical database-bound Django apps, the difference is smaller. Load test your own app before deciding.

Should Django use Granian's WSGI or ASGI interface?

Use WSGI for synchronous apps, which is most Django projects. Use ASGI when you have async views or long-lived connections that benefit from it.

Do I still need Nginx with Granian?

For most deployments, yes. Nginx handles TLS certificates, static files, upload limits, buffering, and rate limiting in front of the application server.

Does Granian replace Uvicorn for Django Channels?

It can run the Channels ASGI application with the asgi interface, including WebSockets. Test your consumers under load before switching.

Keep going