Deploying Django securely
Deployment is where a working Django project either becomes a real service or becomes an incident. Django ships with
good defaults, but startproject produces a development configuration — the job is to change everything that was
convenient into something that is safe.
Settings that must change
import os
DEBUG = False
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # 50+ random chars, never in git
ALLOWED_HOSTS = ["shop.example.in", "www.shop.example.in"]
CSRF_TRUSTED_ORIGINS = ["https://shop.example.in", "https://www.shop.example.in"]
# HTTPS
SECURE_SSL_REDIRECT = True
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # only behind a trusted proxy
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Cookies
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
X_FRAME_OPTIONS = "DENY"
DEBUG = True in production leaks settings, SQL and stack traces to anyone who triggers an error — it is the single
most common serious misconfiguration. Enable HSTS only once HTTPS genuinely works on every subdomain; browsers
remember it for a year.
Then let Django audit you:
python manage.py check --deploy
| Warning | Meaning |
|---|---|
security.W004 |
SECURE_HSTS_SECONDS not set |
security.W008 |
SECURE_SSL_REDIRECT is off |
security.W012 / W016 |
Session or CSRF cookie not marked secure |
security.W018 |
DEBUG is still True |
Serving the app
runserver is single-threaded and unaudited. In production use a WSGI server behind Nginx or a managed platform:
pip install gunicorn whitenoise
python manage.py collectstatic --noinput
python manage.py migrate --noinput
gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 60
A rough starting point is 2 * CPU cores + 1 workers. Static files are served by WhiteNoise or Nginx, never by
Django itself:
MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware") # right after SecurityMiddleware
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
User uploads (MEDIA_ROOT) belong on object storage, not on the web server's disk, and must never be served with
executable permissions.
Database, backups and data
Use MySQL or PostgreSQL, not SQLite. Set CONN_MAX_AGE = 60 to reuse connections, give the app a database user with
only the privileges it needs, and take an automated daily dump that you restore into a staging database once a
month — an untested backup is a rumour. If you store personal data of Indian users, know where those backups
physically live and how long you keep them; under the DPDP Act, 2023 retention and deletion are obligations, not
preferences.
Operate it
- Ship logs to a file or service with request id, user id and status; set
ADMINSso 500s reach a human. - Add an error tracker and an uptime check on a lightweight health URL.
- Deploy in a fixed order: install dependencies,
migrate,collectstatic, restart workers. - Write backward-compatible migrations so the old code keeps running during a rolling restart.
- Pin versions in
requirements.txtand watch Django security releases; 5.1 gets patches on a schedule.
Common mistakes
ALLOWED_HOSTS = ["*"]to make an error go away.- Setting
SECURE_PROXY_SSL_HEADERwithout a proxy that strips the header, allowing clients to fake HTTPS. - Running
migrateby hand on a Friday evening with no backup and no rollback plan. - Leaving the admin at
/admin/with a weak superuser password and no rate limiting. - Committing
.env, or printing environment variables into logs.
Practice
Take any Django project you have, set DEBUG=False with a real SECRET_KEY and ALLOWED_HOSTS from the
environment, and get python manage.py check --deploy to report zero issues. Then serve it with Gunicorn plus
WhiteNoise and confirm the CSS still loads after collectstatic.