Django Production Deployment
DEBUG=False settings, serving static files with whitenoise, and running Gunicorn behind Nginx.
DEBUG = False
Every Django project ships with DEBUG = True in a freshly generated settings.py — great for local development (detailed error pages showing a full traceback and local variables), and a serious security problem in production, since that same detailed traceback is shown to anyone who triggers an unhandled error, potentially leaking secrets, file paths, or installed package versions.
# settings.py
import os
DEBUG = os.environ.get("DJANGO_DEBUG", "False") == "True"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
With DEBUG = False, Django requires ALLOWED_HOSTS to be set to the actual domain(s) the app is served from — an empty list here rejects every request with a 400 Bad Request, which is deliberate: it stops HTTP Host-header attacks that rely on a server accepting requests for any hostname at all.
Serving static files: whitenoise and collectstatic
Django's dev server serves static files (CSS, JS, images) for you automatically, but that behavior is disabled once DEBUG = False — a production deployment needs an explicit plan for static files. WhiteNoise is the simplest option: a small library that lets your Django app itself serve static files efficiently, with no separate web server configuration needed.
pip install whitenoise
# settings.py
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", # right after SecurityMiddleware
# ...
]
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
python3 manage.py collectstatic --noinput
collectstatic walks every app's static/ directory and copies every static file into one place (STATIC_ROOT) — the single directory WhiteNoise (or Nginx, if serving static files that way instead) actually serves from in production. This has to be re-run every time static assets change, typically as a step in the deployment process itself, not something run manually against a live server.
Running with Gunicorn behind Nginx
The Django development server (manage.py runserver) is explicitly unfit for production traffic — it's single-threaded and not hardened for the open internet. Gunicorn is a production-grade WSGI server that actually runs your Django app:
pip install gunicorn
gunicorn mysite.wsgi:application --bind 0.0.0.0:8000 --workers 3
mysite.wsgi:application points Gunicorn at the application callable Django's wsgi.py already defines. --workers 3 runs three separate worker processes handling requests concurrently — a reasonable starting point is (2 × CPU cores) + 1.
Gunicorn is rarely exposed to the internet directly — it sits behind Nginx, which terminates TLS, serves static/media files directly from disk (faster than round-tripping through Gunicorn for that), and reverse-proxies everything else to Gunicorn:
server {
listen 80;
server_name example.com;
location /static/ {
alias /var/www/mysite/staticfiles/;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
| Layer | Responsibility |
|---|---|
| Nginx | TLS termination, serving static files directly, load balancing across multiple Gunicorn processes/machines |
| Gunicorn | Running your actual Django application code, managing worker processes |
| Django | Your application logic, unaware of anything in front of it |
Common mistakes
- Deploying with
DEBUG = Trueleft on — beyond leaking a detailed traceback (including settings values) to any visitor who triggers an error, Django also skips itsALLOWED_HOSTScheck entirely whileDEBUGisTrue. - Forgetting to run
collectstaticas part of the deploy process — static files served from app code during development simply 404 in production until it's run. - Running Gunicorn (or any WSGI server) directly exposed to the internet with no reverse proxy in front of it — missing out on TLS termination, request buffering, and static file serving that Nginx handles far better than an application server should.
Interview questions
Q: Why is DEBUG = False critical in production, beyond just "hiding stack traces"?
With DEBUG = True, an unhandled exception shows a detailed page with the full traceback, local variable values at every stack frame, and Django/package version info to any visitor who triggers it — a serious information leak. Just as importantly, Django only enforces its ALLOWED_HOSTS allow-list when DEBUG = False; with debug mode on, that Host-header validation is skipped entirely, widening the attack surface further.
Q: What's the division of responsibility between Nginx and Gunicorn in a typical Django production deployment? Nginx sits at the edge, terminating TLS, serving static/media files directly from disk (far cheaper than routing them through the application), and reverse-proxying everything else to Gunicorn — it can also load-balance across multiple Gunicorn worker processes or machines. Gunicorn's job is narrower: run the actual Django WSGI application and manage a pool of worker processes so it can handle several requests concurrently. Neither is meant to do the other's job — running Gunicorn directly on the internet loses TLS termination and efficient static file handling; asking Nginx to run Python application code isn't what it's built for at all.