Django Advanced #7: Deployment security — settings split, ALLOWED_HOSTS, CSRF, secret management

12 min read

The final post of the Django Advanced series. Building on the cookie security options from Intermediate #5 Messages/sessions/cookies, we go through the items you must verify before deploying to production — from settings split, to HSTS, secret management, and the automated check command.

Django ships with developer-friendly defaults. Many of those become risky if they go straight to production unchanged. The goal of this post is to close that gap.

Big picture #

Group production security into three axes:

AxisKeywords
Environment isolationsettings split, env vars, secret management
Transport securityHTTPS, HSTS, secure cookies, CSRF, proxy headers
Information disclosureDEBUG, ALLOWED_HOSTS, error pages, logs

Settings split — two paths #

Pattern 1: Split by file #

Layout
myproject/
├── settings/
│   ├── __init__.py
│   ├── base.py        # common
│   ├── dev.py         # development
│   ├── test.py        # testing
│   └── prod.py        # production
settings/base.py
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent.parent

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    ...
    "myapp",
]

MIDDLEWARE = [...]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ.get("DB_NAME"),
        ...
    }
}

# ⚠ Never put secret values here
settings/dev.py
from .base import *

DEBUG = True
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
SECRET_KEY = "dev-only-do-not-use-in-prod"
INSTALLED_APPS += ["debug_toolbar"]
INTERNAL_IPS = ["127.0.0.1"]
settings/prod.py
from .base import *
import os

DEBUG = False
SECRET_KEY = os.environ["SECRET_KEY"]   # Missing → KeyError → fail fast
ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")

# ... production-only settings (sections below)
Run
DJANGO_SETTINGS_MODULE=myproject.settings.prod gunicorn myproject.wsgi:application

Or via env var on manage.py’s default:

manage.py
os.environ.setdefault(
    "DJANGO_SETTINGS_MODULE",
    "myproject.settings.dev",   # default dev, override in prod via env
)

Pattern 2: Single file + env-var branching #

settings.py — single file
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

ENVIRONMENT = os.environ.get("DJANGO_ENV", "development")
PRODUCTION = ENVIRONMENT == "production"

DEBUG = not PRODUCTION

if PRODUCTION:
    SECRET_KEY = os.environ["SECRET_KEY"]
    ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")
    DATABASES = {
        "default": {
            "ENGINE": "django.db.backends.postgresql",
            "NAME": os.environ["DB_NAME"],
            "USER": os.environ["DB_USER"],
            "PASSWORD": os.environ["DB_PASSWORD"],
            "HOST": os.environ["DB_HOST"],
            "PORT": os.environ.get("DB_PORT", "5432"),
        }
    }
else:
    SECRET_KEY = "dev-secret"
    ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
    DATABASES = {
        "default": {
            "ENGINE": "django.db.backends.sqlite3",
            "NAME": BASE_DIR / "db.sqlite3",
        }
    }
Split by fileSingle file
ReadabilityClear (where what lives)Compare in one place
Tracking diffs between envsHard via file diffIntuitive
Adding a new envNew fileOne if line
Big projectGoodSoon complex
Small projectOverkillEnough

For small to mid-size projects, single file + env vars; for big projects, split by file is typical.

Env var validation — django-environ / pydantic-settings #

Raw os.environ is prone to missing keys and type-conversion mistakes. Use a validator instead.

django-environ #

Install
pip install django-environ
settings.py
import environ

env = environ.Env(
    DEBUG=(bool, False),
    ALLOWED_HOSTS=(list, []),
    DATABASE_URL=(str, ""),
)
environ.Env.read_env()   # Load .env file (if present)

DEBUG = env("DEBUG")
SECRET_KEY = env("SECRET_KEY")
ALLOWED_HOSTS = env("ALLOWED_HOSTS")

# DATABASE_URL fills DB settings in one line
DATABASES = {"default": env.db()}

# CACHE_URL also one line
CACHES = {"default": env.cache()}

.env file (never commit to git):

.env
DEBUG=False
SECRET_KEY=...
ALLOWED_HOSTS=myapp.com,www.myapp.com
DATABASE_URL=postgres://user:pass@db:5432/mydb
CACHE_URL=redis://cache:6379/1

A single DATABASE_URL fills ENGINE/NAME/USER/PASSWORD/HOST/PORT — a good fit for 12-factor-style deployments.

pydantic-settings #

You can use the same one from Modern Python FastAPI in Django too.

myproject/config.py
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    debug: bool = False
    secret_key: str
    allowed_hosts: list[str] = []
    database_url: str

    model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)

settings = Settings()   # Missing var → ValidationError at startup
settings.py
from myproject.config import settings as cfg

DEBUG = cfg.debug
SECRET_KEY = cfg.secret_key
ALLOWED_HOSTS = cfg.allowed_hosts

The big advantage: validation happens at startup — no surprise KeyError at runtime.

Meaning of DEBUG=False #

When DEBUG=True leaks into production:

  • Error pages expose settings, env vars, stack traces, SQL queries → SECRET_KEY may even be visible
  • Django serves static files itself (slow, load)
  • ALLOWED_HOSTS is ignored (no host header validation)

With DEBUG=False:

  • 404 and 500 pages are simplified — no detail
  • ALLOWED_HOSTS is enforced (mismatch → 400)
  • Static files aren’t served directly (you need collectstatic + nginx/CDN)

Custom error pages #

myapp/views.py
def custom_404(request, exception):
    return render(request, "404.html", status=404)

def custom_500(request):
    return render(request, "500.html", status=500)
urls.py
handler404 = "myapp.views.custom_404"
handler500 = "myapp.views.custom_500"

Building both pages before production deployment gives you friendly error screens with images and styles.

ALLOWED_HOSTS #

Active when DEBUG=False. If the request’s Host: header isn’t in this list, 400 Bad Request.

prod
ALLOWED_HOSTS = ["myapp.com", "www.myapp.com"]

Wildcard pitfall #

🚫 Dangerous
ALLOWED_HOSTS = ["*"]

Allows every host — leaves you defenseless against DNS rebinding and host header attacks. Never put this in production.

✅ Subdomain wildcard
ALLOWED_HOSTS = [".myapp.com"]   # Leading dot — means *.myapp.com

Health check IP #

If load-balancer health checks hit by IP directly, the host header is the IP. Spell that out too:

ALB / k8s health check
ALLOWED_HOSTS = ["myapp.com", ".myapp.com", "10.0.0.0/8"]   # CIDR also works (5.x)

Or write a middleware that handles the health-check path separately.

SECRET_KEY management #

Django uses it to sign sessions, CSRF, password reset tokens, etc. Leak = session forgery possible.

Rules:

  • Never in git — use .env, secrets manager (AWS SM, Vault, GCP Secret Manager)
  • Different value per environment
  • Never in logs
  • Never in docs/README

Generating #

New key
from django.core.management.utils import get_random_secret_key
print(get_random_secret_key())

Or:

Quick one-liner
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Rotation #

When you need to change the key (leak, regular rotation):

Since Django 4.1, SECRET_KEY_FALLBACKS exists. Sign with the new key, but accept sessions signed by the old key for a while.

Rotation procedure
SECRET_KEY = "new-key"
SECRET_KEY_FALLBACKS = ["old-key"]   # Keep briefly

After a few days, when old sessions have all expired, remove the fallback.

CSRF — Cross-Site Request Forgery #

Django activates CSRF token validation by default. POST/PUT/DELETE requires csrfmiddlewaretoken or X-CSRFToken header.

CSRF_TRUSTED_ORIGINS — required since 4.0+ #

If forms are POSTed to your site from another domain (subdomains, payment callbacks, etc.):

Trusted origins
CSRF_TRUSTED_ORIGINS = [
    "https://myapp.com",
    "https://www.myapp.com",
    "https://payments.example.com",
]

Including the scheme (https://) is required. Mandated since 4.0.

Cookie options #

prod
CSRF_COOKIE_SECURE = True       # HTTPS only
SESSION_COOKIE_SECURE = True    # Same for session

CSRF_COOKIE_HTTPONLY = False    # JS must read the token to put in headers
SESSION_COOKIE_HTTPONLY = True  # Block JS access for sessions

CSRF_COOKIE_SAMESITE = "Lax"    # CSRF default defense
SESSION_COOKIE_SAMESITE = "Lax"
Cookie optionMeaning
SecureSent over HTTPS only
HttpOnlyBlocks JS (document.cookie) access
SameSite=LaxNot auto-attached to requests from other sites (top-level GET only)
SameSite=StrictNever attached on cross-site requests
SameSite=NoneAttached on every request (Secure required)

The options from Intermediate #5 come together here in production.

HTTPS / HSTS #

SECURE_SSL_REDIRECT #

HTTP → HTTPS auto redirect
SECURE_SSL_REDIRECT = True

Django responds 301 redirect from HTTP to HTTPS. Usually nginx/ALB handles this, but adding it once at Django is safer.

SECURE_PROXY_SSL_HEADER — when behind a proxy #

If a reverse proxy (nginx, ALB, Cloudflare) terminates SSL, Django sees the request as HTTP. The proxy must tell us via a header that the original was HTTPS.

Behind proxy
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

⚠ Risk of header forgery if you’re not behind a trusted proxy. Only enable in environments where the proxy always overwrites this header.

HSTS — Strict-Transport-Security #

A header telling browsers “this domain is HTTPS only”.

HSTS
SECURE_HSTS_SECONDS = 31536000           # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

⚠ Start with a small value (e.g., 60 seconds). Once HSTS is cached in the browser, HTTP access is blocked for that entire period — if a certificate problem arises, users can’t reach the site at all. Increase the value gradually after confirming stability.

Preload list #

Combined with SECURE_HSTS_PRELOAD = True, registering at hstspreload.org puts you on the browsers’ built-in HTTPS-forcing list. HTTP attempts don’t even happen on first visit. Register carefully — removal takes months.

Other security headers #

X_FRAME_OPTIONS #

Clickjacking defense
X_FRAME_OPTIONS = "DENY"

Django default is SAMEORIGIN. Prevents your site from being embedded in an iframe to block clickjacking. Especially important for payment and login pages.

Content Security Policy (CSP) #

The last line of defense for XSS, etc. django-csp is the standard.

Install
pip install django-csp
settings.py
MIDDLEWARE = [..., "csp.middleware.CSPMiddleware"]

CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'", "https://cdn.jsdelivr.net")
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'")
CSP_IMG_SRC = ("'self'", "data:", "https:")
CSP_CONNECT_SRC = ("'self'", "https://api.myapp.com")

Start in report-only mode — violations are reported instead of blocked:

Report-only
CSP_REPORT_ONLY = True
CSP_REPORT_URI = "/csp-report/"

Monitor for a few days, fix violations, tune the policy, then switch to enforce mode.

SECURE_CONTENT_TYPE_NOSNIFF, SECURE_REFERRER_POLICY #

Misc
SECURE_CONTENT_TYPE_NOSNIFF = True       # Default True (5.x)
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"

Passwords — hashers, validators #

PASSWORD_HASHERS — strong hash #

Order — first item is used for new passwords
PASSWORD_HASHERS = [
    "django.contrib.auth.hashers.Argon2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
]

The default is PBKDF2 — secure enough. Argon2 is the modern standard (won the 2015 Password Hashing Competition). Extra package required:

argon2
pip install argon2-cffi

Add the hasher and put it first — new passwords use Argon2, and existing PBKDF2 passwords are auto re-hashed to Argon2 on the next login.

AUTH_PASSWORD_VALIDATORS #

Validators — default + alpha
AUTH_PASSWORD_VALIDATORS = [
    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
    {
        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
        "OPTIONS": {"min_length": 12},   # 8 → 12 recommended
    },
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]

NIST guidance recommends a minimum of 12 characters. The modern recommendation: length matters more than complexity rules like forced special characters.

Logging #

Production logging pattern #

settings/prod.py
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "verbose": {
            "format": "{levelname} {asctime} {name} {process:d} {message}",
            "style": "{",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "verbose",
        },
    },
    "root": {
        "handlers": ["console"],
        "level": "INFO",
    },
    "loggers": {
        "django.request": {
            "handlers": ["console"],
            "level": "ERROR",
            "propagate": False,
        },
        "django.security": {
            "handlers": ["console"],
            "level": "INFO",
            "propagate": False,
        },
    },
}

Rules:

  • To stdout/stderr — let containers/systemd collect
  • No file logging (rotation, permissions, disk — all headaches)
  • Send ERROR to a separate alerting channel (Sentry, etc.)

Sentry integration #

Install
pip install "sentry-sdk[django]"
settings/prod.py
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration

sentry_sdk.init(
    dsn=os.environ["SENTRY_DSN"],
    integrations=[DjangoIntegration()],
    traces_sample_rate=0.1,
    send_default_pii=False,    # Don't auto-collect PII
)

The de facto standard for production error tracking.

manage.py check --deploy #

Django’s automated deployment-check command. It checks security recommendations and warns.

Run
DJANGO_SETTINGS_MODULE=myproject.settings.prod python manage.py check --deploy

Sample output:

?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting...
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True.
?: (security.W012) SESSION_COOKIE_SECURE is not set to True.
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True.
?: (security.W018) You should not have DEBUG set to True in deployment.
?: (security.W019) You have 'django.middleware.clickjacking.XFrameOptionsMiddleware' in your MIDDLEWARE, but X_FRAME_OPTIONS is not set to 'DENY'.
?: (security.W020) ALLOWED_HOSTS must not be empty in deployment.

Add it to CI and block deployment if it fails — recommended as a required gate before production.

.github/workflows/deploy.yml — excerpt
- name: Django deploy check
  run: |
    DJANGO_SETTINGS_MODULE=myproject.settings.prod \
    python manage.py check --deploy --fail-level WARNING

Secret management — where to put them #

1) .env + server disk #

Small projects. Keep .env out of git, place directly on the server. Simple, but weak on rotation, multi-environment, permissions.

2) Cloud secrets manager #

  • AWS Secrets Manager / AWS Systems Manager Parameter Store
  • GCP Secret Manager
  • Azure Key Vault

Authenticate at runtime via IAM and fetch. Comes with rotation automation and audit logs.

settings.py — boto3 example
import boto3, json

if PRODUCTION:
    client = boto3.client("secretsmanager")
    secret = json.loads(client.get_secret_value(SecretId="myapp/prod")["SecretString"])
    SECRET_KEY = secret["django_secret"]
    ...

3) HashiCorp Vault #

Self-hosted / multi-cloud environments.

4) Kubernetes Secrets / Sealed Secrets #

A natural fit when on Kubernetes. Sealed Secrets lets you store encrypted form in git.

What to never do #

  • Plaintext in code/git
  • README, wiki, Slack
  • Plaintext env vars in a git-tracked docker-compose.yml

Common pitfalls #

1) DEBUG=True in production #

Most common and most catastrophic. Block in CI:

In CI
python -c "import django; from django.conf import settings; assert not settings.DEBUG"

Or manage.py check --deploy --fail-level WARNING.

2) SECRET_KEY in git history #

Even if a SECRET_KEY was added once and then removed, it lives forever in git history. Rotate the key immediately and clean the history with git filter-branch or BFG.

3) Missing static files #

With DEBUG=False, Django doesn’t serve static files. Always include collectstatic in your deployment step, and serve via nginx/CDN/whitenoise.

Deploy step
python manage.py collectstatic --noinput

whitenoise is a middleware that lets Django serve static files itself — convenient for simple sites.

4) Empty response when ALLOWED_HOSTS is missing #

Deployed and every request returns 400 Bad Request — almost certainly missing ALLOWED_HOSTS. Django logs say so clearly.

5) Trusting ALB/Cloudflare’s X-Forwarded-* #

You enable SECURE_PROXY_SSL_HEADER, but in an environment where requests in front of the proxy can forge headers, every request gets recognized with the fake X-Forwarded-For IP instead of the real one. Only in topologies where the proxy always overwrites the header.

6) Admin exposure #

Leaving /admin/ as-is makes it the #1 target for bot attacks. Recommendations:

  • Change the URL (/secret-admin-path-12345/)
  • IP restrict (at nginx)
  • 2FA (django-otp)
  • Access only from inside a VPN

Pre-flight checklist #

Before deployment, run through:

  • DEBUG = False
  • SECRET_KEY from environment, not in git
  • ALLOWED_HOSTS set (no *)
  • HTTPS enforced (SECURE_SSL_REDIRECT)
  • HSTS configured (increase gradually)
  • SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE = True
  • CSRF_TRUSTED_ORIGINS set
  • X_FRAME_OPTIONS = "DENY"
  • CSP configured (start in report-only)
  • Password validator length 12+
  • Argon2 or PBKDF2
  • manage.py check --deploy passes
  • Static files: collectstatic + serving
  • Error tracking (Sentry, etc.)
  • Backup automation (DB)
  • Admin protected

Wrap-up #

What you covered this time:

  • Settings split: file split or env-var branching — small projects use the latter
  • Env validation: django-environ (DATABASE_URL one-liner), pydantic-settings
  • What DEBUG=False triggers — ALLOWED_HOSTS active, no static-file serving, no error details
  • ALLOWED_HOSTS wildcard pitfall, health-check IP handling
  • SECRET_KEY — never in git, secrets manager, rotation via SECRET_KEY_FALLBACKS
  • CSRF: CSRF_TRUSTED_ORIGINS (required since 4.0), cookie options (Secure/HttpOnly/SameSite)
  • HTTPS: SECURE_SSL_REDIRECT, SECURE_PROXY_SSL_HEADER, HSTS (gradual), preload
  • Headers: X_FRAME_OPTIONS=DENY, django-csp (start with report-only)
  • Passwords: Argon2, length 12+
  • Logging: stdout, ERROR to Sentry
  • manage.py check --deploy — CI gate
  • Secret management: secrets manager recommended; never in git/README
  • Pitfalls: DEBUG leaking, SECRET_KEY history, missing collectstatic, X-Forwarded forgery, /admin/ exposure
  • Use the checklist before deployment

Wrapping up the series #

Basics (7 posts)Intermediate (7 posts) → Advanced (7 posts) — 21 posts of Django toolbox in one place.

What remains is the step of stacking an API on top, in one project. In the next series Django DRF #1, we build a REST API in earnest on the same Django. DRF’s ViewSet/Serializer/Permission, JWT, pagination, automatic OpenAPI docs, Celery async tasks, testing and deployment — six posts that build a production-ready Django API project end to end.

X