Django Advanced #7: Deployment security — settings split, ALLOWED_HOSTS, CSRF, secret management
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:
| Axis | Keywords |
|---|---|
| Environment isolation | settings split, env vars, secret management |
| Transport security | HTTPS, HSTS, secure cookies, CSRF, proxy headers |
| Information disclosure | DEBUG, ALLOWED_HOSTS, error pages, logs |
Settings split — two paths #
Pattern 1: Split by file #
myproject/
├── settings/
│ ├── __init__.py
│ ├── base.py # common
│ ├── dev.py # development
│ ├── test.py # testing
│ └── prod.py # productionimport 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 herefrom .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"]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)DJANGO_SETTINGS_MODULE=myproject.settings.prod gunicorn myproject.wsgi:applicationOr via env var on manage.py’s default:
os.environ.setdefault(
"DJANGO_SETTINGS_MODULE",
"myproject.settings.dev", # default dev, override in prod via env
)Pattern 2: Single file + env-var branching #
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 file | Single file | |
|---|---|---|
| Readability | Clear (where what lives) | Compare in one place |
| Tracking diffs between envs | Hard via file diff | Intuitive |
| Adding a new env | New file | One if line |
| Big project | Good | Soon complex |
| Small project | Overkill | Enough |
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 #
pip install django-environimport 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):
DEBUG=False
SECRET_KEY=...
ALLOWED_HOSTS=myapp.com,www.myapp.com
DATABASE_URL=postgres://user:pass@db:5432/mydb
CACHE_URL=redis://cache:6379/1A 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.
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 startupfrom myproject.config import settings as cfg
DEBUG = cfg.debug
SECRET_KEY = cfg.secret_key
ALLOWED_HOSTS = cfg.allowed_hostsThe 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_HOSTSis ignored (no host header validation)
With DEBUG=False:
- 404 and 500 pages are simplified — no detail
ALLOWED_HOSTSis enforced (mismatch → 400)- Static files aren’t served directly (you need
collectstatic+ nginx/CDN)
Custom error pages #
def custom_404(request, exception):
return render(request, "404.html", status=404)
def custom_500(request):
return render(request, "500.html", status=500)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.
ALLOWED_HOSTS = ["myapp.com", "www.myapp.com"]Wildcard pitfall #
ALLOWED_HOSTS = ["*"]Allows every host — leaves you defenseless against DNS rebinding and host header attacks. Never put this in production.
ALLOWED_HOSTS = [".myapp.com"] # Leading dot — means *.myapp.comHealth check IP #
If load-balancer health checks hit by IP directly, the host header is the IP. Spell that out too:
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 #
from django.core.management.utils import get_random_secret_key
print(get_random_secret_key())Or:
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.
SECRET_KEY = "new-key"
SECRET_KEY_FALLBACKS = ["old-key"] # Keep brieflyAfter 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.):
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 #
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 option | Meaning |
|---|---|
Secure | Sent over HTTPS only |
HttpOnly | Blocks JS (document.cookie) access |
SameSite=Lax | Not auto-attached to requests from other sites (top-level GET only) |
SameSite=Strict | Never attached on cross-site requests |
SameSite=None | Attached on every request (Secure required) |
The options from Intermediate #5 come together here in production.
HTTPS / HSTS #
SECURE_SSL_REDIRECT
#
SECURE_SSL_REDIRECT = TrueDjango 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.
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”.
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
#
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.
pip install django-cspMIDDLEWARE = [..., "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:
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
#
SECURE_CONTENT_TYPE_NOSNIFF = True # Default True (5.x)
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"Passwords — hashers, validators #
PASSWORD_HASHERS — strong hash
#
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:
pip install argon2-cffiAdd 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
#
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 #
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 #
pip install "sentry-sdk[django]"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.
DJANGO_SETTINGS_MODULE=myproject.settings.prod python manage.py check --deploySample 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.
- name: Django deploy check
run: |
DJANGO_SETTINGS_MODULE=myproject.settings.prod \
python manage.py check --deploy --fail-level WARNINGSecret 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.
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:
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.
python manage.py collectstatic --noinputwhitenoise 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_KEYfrom environment, not in git -
ALLOWED_HOSTSset (no*) - HTTPS enforced (
SECURE_SSL_REDIRECT) - HSTS configured (increase gradually)
-
SESSION_COOKIE_SECURE,CSRF_COOKIE_SECURE= True -
CSRF_TRUSTED_ORIGINSset -
X_FRAME_OPTIONS = "DENY" - CSP configured (start in report-only)
- Password validator length 12+
- Argon2 or PBKDF2
-
manage.py check --deploypasses - 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_URLone-liner),pydantic-settings - What
DEBUG=Falsetriggers — ALLOWED_HOSTS active, no static-file serving, no error details ALLOWED_HOSTSwildcard pitfall, health-check IP handlingSECRET_KEY— never in git, secrets manager, rotation viaSECRET_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.