Deployment: gunicorn, static files and the security checklist
Serve Django behind gunicorn and nginx, collect static files as part of the release, and turn the production checklist into a command you run in CI.
gunicorn behind a reverse proxy
pip install "gunicorn~=23.0"
export DJANGO_SETTINGS_MODULE=config.settings.production
gunicorn config.wsgi:application \
--bind 127.0.0.1:8000 \
--workers 3 \
--threads 2 \
--timeout 60 \
--max-requests 1000 --max-requests-jitter 100 \
--access-logfile - --error-logfile -
# async project (ASGI) instead:
uvicorn config.asgi:application --host 127.0.0.1 --port 8000 --workers 3upstream app {
server 127.0.0.1:8000 fail_timeout=0;
}
server {
listen 443 ssl http2;
server_name example.com;
client_max_body_size 5m; # must match your upload limit
location /static/ {
alias /srv/app/staticfiles/;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /media/ {
alias /srv/app/media/;
}
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 65s; # slightly longer than gunicorn's timeout
}
}runserveris single-process, single-threaded, self-reloading and warns about it on every boot. It is a development tool and nothing more.- A worker is a process. In-memory state — a
LocMemCache, a module-level dictionary, an uploaded file on local disk — exists once per worker, so anything shared must live in Redis, the database or object storage. - Size workers by memory, not by a formula.
(2 x cores) + 1is a starting guess, but if one worker holds 200 MB, eight workers on a 1 GB host will be killed by the OOM reaper. - Long requests need raised timeouts on both sides, and a request that takes 60 seconds is usually a design problem: move the work to a background task and return an accepted status.
The release sequence
set -e
python manage.py migrate --noinput # 1. schema first, backwards compatible
python manage.py collectstatic --noinput # 2. assets into STATIC_ROOT
python manage.py check --deploy # 3. fail the release on a red check
# 4. restart the application processes
systemctl restart app-gunicorn
# 5. smoke test the health endpoint through the proxy
curl -fsS https://example.com/healthz| Step | Command | Why it is here |
|---|---|---|
| Build | install pinned requirements | The artifact must be reproducible |
| Schema | migrate --noinput | New code expects new columns |
| Assets | collectstatic --noinput | Templates reference hashed filenames |
| Config audit | check --deploy | Turns the checklist into a failing exit code |
| Restart | systemd / platform restart | Python loads settings and code at import time |
| Verify | health check plus a log read | A process that started is not a process that works |
Run migrations once, from the release job, never from the application's start-up hook. Ten workers booting together will each try to apply the same migration; the losers hit a lock or an inconsistent history.
The production security checklist
# config/settings/production.py
DEBUG = False # otherwise every traceback leaks settings
SECRET_KEY = env("DJANGO_SECRET_KEY") # long, random, never in git
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS") # Host header validation
CSRF_TRUSTED_ORIGINS = ["https://example.com"]
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
# do not expose the admin on a guessable public path
ADMIN_URL = env("ADMIN_URL", default="control-room/")python manage.py check --deployreports every setting above that is wrong for production. Run it with the production settings module in CI so a missing flag fails the build.- Uploads, dependencies and permissions are outside what the command can see. Add
pip-auditto CI, keep an allowlist of uploadable extensions, and review who holdsis_staff. - Log the deployed commit and expose a tiny health endpoint that checks the database and the cache. Neither is security, but both turn a bad release into a two-minute rollback.
⚠️
DEBUG = True in production is the most damaging single mistake in this list. Anyone who triggers an exception receives the traceback, the full settings dump including SECRET_KEY and database credentials, and the list of installed apps — enough to forge session cookies and read your data. Keep DEBUG=False, set a real ADMINS email so errors still reach you, and confirm the flag in the running process rather than in the file you believe was deployed.FAQ
Why am I getting a 400 Bad Request with no explanation?
That is
ALLOWED_HOSTS rejecting the Host header. In production list the real domains. Never work around it with ["*"]: it disables the protection that stops host-header poisoning of password-reset links.How do I roll back a bad deploy?
Redeploy the previous commit and restart — but only if the migrations were additive, which is why removal is staged across releases. Rolling back a destructive migration means restoring a backup, so the discipline at migration time is what makes the rollback cheap.
Related
Settings, caching and query performance Static and media files
Last refreshed 2026-09-18.