Django Production Settings Checklist (DEBUG, ALLOWED_HOSTS, CSRF)
A lot of Django production incidents come from settings.py, not application code.
Problem statement
A lot of Django production incidents come from settings.py, not application code.
The common pattern is simple: a project works locally, gets deployed, and production still carries development defaults or half-finished security settings. That usually shows up in one of two ways:
- unsafe exposure, such as
DEBUG = Trueleaking detailed error pages - broken traffic, such as bad
ALLOWED_HOSTSor CSRF settings causing 400 or 403 errors after deployment
This page is a practical Django production settings checklist focused on the minimum settings you should verify before and after a release:
SECRET_KEYDEBUGALLOWED_HOSTSCSRF_TRUSTED_ORIGINS- proxy and HTTPS settings that affect host and CSRF validation
It does not try to cover full database tuning, complete secrets management, or every security header Django supports.
Quick answer
Before your first production deploy:
- load a unique production
SECRET_KEYfrom the environment or secret manager - set
DEBUG = False - define explicit
ALLOWED_HOSTS - configure
CSRF_TRUSTED_ORIGINSonly when your deployment actually needs it - make sure Django correctly detects HTTPS when you are behind Nginx, Caddy, or a load balancer
- run
python manage.py check --deploy - verify with real requests, a form POST, and log inspection after restart
If any of these changes break traffic, roll back to the previous environment file or previous release artifact, restart the app, confirm the old service is healthy, and re-run smoke tests.
Step-by-step solution
1. Start with separate production settings
Do not let production inherit local defaults by accident.
Two common layouts are fine:
- one
settings.pywith environment-variable driven values - split modules such as
base.py,development.py, andproduction.py
A simple split example:
# mysite/settings/production.py
from .base import *
import os
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = False
ALLOWED_HOSTS = [h.strip() for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h.strip()]
CSRF_TRUSTED_ORIGINS = [o.strip() for o in os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",") if o.strip()]
A safer boolean parser for environment variables:
import os
def env_bool(name, default=False):
return os.environ.get(name, str(default)).lower() in {"1", "true", "yes", "on"}
DEBUG = env_bool("DJANGO_DEBUG", default=False)
If you use a single settings file, still default to safe production behavior unless you explicitly enable development mode.
Verify
Run a deploy check before restart:
python manage.py check --deploy
Rollback note
Keep the previous .env file or previous release config available. If host or CSRF validation breaks production, restore the last known-good values, restart the application server, and confirm the previous process came back cleanly in logs and health checks.
2. Load a real production SECRET_KEY
A production deployment should never use a development, test, or placeholder secret key.
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
Example environment value generation:
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
Why it matters
SECRET_KEY is used for Django cryptographic signing, including sessions, password reset tokens, and other security-sensitive features. A reused or committed key weakens the whole deployment.
What to avoid
Do not:
- hardcode a shared dev key into production settings
- commit the production key to Git
- reuse the same key across unrelated projects
Verify
Confirm the environment variable is present in the runtime environment your app actually uses, then restart and check logs:
journalctl -u gunicorn -n 50 --no-pager
Or in Docker:
docker compose logs web --tail=50
Rollback note
If a bad key value was deployed and the app fails to start, restore the previous environment file or secret reference, restart the app, and verify the old process is serving traffic again.
3. Set DEBUG = False
This is the first item in any Django production checklist.
DEBUG = False
If you load it from the environment:
DEBUG = env_bool("DJANGO_DEBUG", default=False)
Why it matters
With DEBUG = True, Django may expose stack traces, settings context, paths, and other internal details through browser error pages. It also changes assumptions around static files and error handling.
Verify
Trigger a harmless missing page and confirm you do not see a Django traceback:
curl -I https://example.com/this-page-does-not-exist
For server-side confirmation, inspect logs instead of browser output:
journalctl -u gunicorn -n 100 --no-pager
Or in Docker:
docker compose logs web --tail=100
Failure patterns when DEBUG is left on
- detailed exception pages visible to users
- leaked configuration details
- confusion around static file behavior that only worked in development
4. Set ALLOWED_HOSTS explicitly
ALLOWED_HOSTS protects Django against invalid or unexpected Host headers.
A typical production example:
ALLOWED_HOSTS = [
"example.com",
"www.example.com",
]
From an environment variable:
ALLOWED_HOSTS = [
h.strip()
for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
if h.strip()
]
Example environment value:
DJANGO_ALLOWED_HOSTS=example.com,www.example.com
What to include
Include only hosts that should actually serve the app:
- primary domain
wwwdomain if used- internal hostname only if your architecture really sends that host to Django
- IP address only if you intentionally support direct IP access
What to avoid
Do not use:
ALLOWED_HOSTS = ["*"]
That removes an important validation layer and can hide routing or proxy mistakes.
Also remove stale domains from old staging or preview environments.
Reverse proxy note
Your reverse proxy should pass the original host header through to Django. For Nginx:
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;
}
If the proxy rewrites Host incorrectly, Django host validation becomes misleading.
Verify
Check the expected host:
curl -I https://example.com/
Then check an unexpected host using a method that still reaches Django, such as plain HTTP on the server IP inside the trusted network or curl --resolve for HTTPS:
curl -I --resolve bad.example.com:443:server-ip https://bad.example.com/
The bad host request should fail cleanly rather than being served as your real site.
Rollback
If valid traffic suddenly starts returning 400 Bad Request, restore the previous host list, restart the app, verify the proxy is forwarding the correct Host header, and confirm the old process is healthy before ending the rollback.
5. Configure CSRF safely for production
CSRF_TRUSTED_ORIGINS is not always required, but when it is required, it must be exact.
Typical example:
CSRF_TRUSTED_ORIGINS = [
"https://example.com",
"https://www.example.com",
]
Environment-driven version:
CSRF_TRUSTED_ORIGINS = [
o.strip()
for o in os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS", "").split(",")
if o.strip()
]
Example environment value:
DJANGO_CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
When it is needed
You commonly need this when:
- admin or forms are accessed through a different trusted origin
- you intentionally allow cross-origin browser POSTs
- Django is rejecting secure form submissions because the request origin seen by Django does not match the trusted origin, often due to proxy/scheme configuration
Correct format
Use full origins with scheme:
https://example.comhttps://admin.example.com
Do not omit the scheme.
Common mistakes
- missing
https:// - trusting too many origins
- adding domains that should not be posting to the app
- assuming HTTPS behind a proxy automatically requires this setting
- forgetting proxy SSL configuration, so Django thinks the request is HTTP and rejects the origin check
Test after deploy
Use a browser for real CSRF validation:
- load the admin login page over HTTPS
- log in
- submit an authenticated form
- confirm no 403 CSRF failure occurs
Then inspect logs if it fails:
journalctl -u gunicorn -n 100 --no-pager
or
docker compose logs web --tail=100
6. Set proxy and HTTPS-related settings correctly
These settings affect both CSRF and secure request handling.
SECURE_PROXY_SSL_HEADER
If TLS terminates at Nginx, Caddy, or a load balancer and Django receives plain HTTP from that proxy, Django needs a trusted signal for the original scheme.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
Only set this if your proxy actually sends X-Forwarded-Proto: https and you control that proxy layer. If multiple proxies or load balancers are involved, only trust forwarded headers from your own upstream infrastructure.
USE_X_FORWARDED_HOST
Leave this off unless your proxy architecture requires Django to trust X-Forwarded-Host.
USE_X_FORWARDED_HOST = False
Enabling it unnecessarily widens the set of upstream headers that influence host validation.
SECURE_SSL_REDIRECT and secure cookies
If the public site should always use HTTPS:
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
These settings help keep login and form traffic on HTTPS and prevent insecure cookie transport.
Verify
curl -I https://example.com/
Check for expected redirects and behavior. Then test login and form submission in a browser, because cookie and CSRF issues usually appear there first.
7. Run the deployment verification checklist
Required checks
- production
SECRET_KEYis unique, non-default, and not committed in code DEBUG = False- explicit
ALLOWED_HOSTS - valid
CSRF_TRUSTED_ORIGINSif needed - correct
SECURE_PROXY_SSL_HEADERbehind a TLS-terminating proxy - secure cookie settings reviewed
- custom error handling and logs confirmed
Useful adjacent checks
Also review:
SECURE_HSTS_SECONDSSECURE_CONTENT_TYPE_NOSNIFFX_FRAME_OPTIONS
Pre-release commands
python manage.py check --deploy
If using systemd:
sudo systemctl restart gunicorn
sudo systemctl status gunicorn --no-pager
If using Docker Compose:
docker compose restart web
docker compose ps
Post-release smoke tests
curl -I https://example.com/
curl -I --resolve bad.example.com:443:server-ip https://bad.example.com/
Then verify manually:
- homepage loads on the correct domain
- admin login works
- a form POST succeeds
- bad host is rejected
- no debug page is visible anywhere
Recovery
If the release breaks host routing or form submissions:
- revert the changed environment variables or settings file
- redeploy the previous release if needed
- restart the app server
- confirm the previous service is healthy in logs or process status
- re-run the same smoke tests
Explanation
This setup works because it aligns Django’s request validation with the way production traffic actually reaches the app.
SECRET_KEYprotects signed data and must be unique per production deploymentDEBUG = Falseremoves development-only behaviorALLOWED_HOSTSmakes host routing explicitCSRF_TRUSTED_ORIGINSallows trusted browser origins when needed, but only when listed precisely- proxy SSL settings let Django understand whether the original request was HTTPS
The main alternative is settings structure, not behavior. You can use one settings file or split modules, but the production values should still be explicit and reviewable.
When to automate this
Once you deploy more than one environment, this checklist should stop being manual. Good first automation targets are environment-variable validation, manage.py check --deploy in CI, and post-deploy smoke tests for expected host, bad host, admin login, and a CSRF-protected form flow. A reusable production settings template and reverse proxy template also reduce repeated mistakes.
Edge cases / notes
- Multiple domains or tenant-style apps: keep the host validation strategy explicit. Dynamic host logic can be correct, but it is easier to get wrong than a fixed host list.
- Preview environments: do not weaken production defaults just to support temporary URLs. Handle preview hosts separately from production.
- Docker deployments: keep images immutable and inject environment-specific values at runtime through env vars or orchestration config.
- Load balancers and health checks: some platforms probe with internal hosts or IPs. Only add those to
ALLOWED_HOSTSif those requests really hit Django and must be accepted. - Static and media files: turning off
DEBUGalso means you should not rely on development-style static serving assumptions in production. Verify your web server or storage setup separately. - Migrations and app restarts: a settings change can fail only after restart, so always pair config changes with log inspection and smoke tests.
Internal links
If you need the broader settings structure behind this checklist, see Django settings for production: environment variables, split settings, and secrets.
For full web server integration, continue with Deploy Django with Gunicorn and Nginx and How to configure HTTPS for Django behind Nginx or Caddy.
If valid traffic is failing with host errors, use How to fix Django 400 Bad Request (Invalid HTTP_HOST header) in production.
For adjacent production topics, see Django Deployment Checklist for Production, Django WSGI vs ASGI: Which One Should You Deploy?, and Django Static vs Media Files in Production.
FAQ
Do I always need CSRF_TRUSTED_ORIGINS in Django production?
No. You need it when Django must trust specific origins for unsafe requests, such as cross-origin form flows or when proxy/scheme handling causes the request origin seen by Django to differ from the browser origin. If your app works correctly without it, do not add unnecessary origins.
What should I put in ALLOWED_HOSTS behind Nginx or a load balancer?
Usually your public domains only, such as example.com and www.example.com. Add internal hosts only if the proxy or platform truly sends those hosts to Django and they must be accepted.
Why does Django admin login fail with a CSRF error after enabling HTTPS?
The common causes are:
- missing or incorrect
CSRF_TRUSTED_ORIGINSwhen the browser origin must be explicitly trusted - missing
SECURE_PROXY_SSL_HEADER - proxy not sending
X-Forwarded-Proto: https - scheme or host mismatch between what the browser uses and what Django sees
- cookies not marked secure in an HTTPS-only deployment
Is ALLOWED_HOSTS = ['*'] ever acceptable in production?
As a general production setting, no. It disables an important safety check and can hide proxy or routing problems. Use an explicit host list instead.
How do I verify that DEBUG is really off after deployment?
Request a missing URL or trigger a controlled error path and confirm you do not see a Django traceback page. Then inspect application logs for the real error details.