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 ADMINS so 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.txt and 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_HEADER without a proxy that strips the header, allowing clients to fake HTTPS.
  • Running migrate by 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.

B

Written by Bablu Kumar Jha

Founder, BeeProton AI · Mumbai, Maharashtra, India