The Django checklist I run before a site goes live

Eleven things to settle before a Django 5.2 site takes real traffic: settings, HSTS, WhiteNoise, migrations, backups, logging and email — with the settings snippets, and a note on what actually breaks first.

python manage.py check --deploy is the first thing to run and the last thing to rely on. It catches six or seven settings mistakes. It says nothing about whether your static files will render, whether your backups restore, or whether the homepage runs 200 queries. This is the list I work through instead, in the order that the mistakes hurt.

Everything below is Django 5.2 with settings read from the environment. The env helper is just os.environ.get with a default.

1. DEBUG and SECRET_KEY

DEBUG = True in production hands a stack trace, your settings names, the SQL of the failing query and absolute filesystem paths to whoever triggers the error. Make the safe value the default, so forgetting a variable fails closed:

DEBUG = env("DJANGO_DEBUG", "0") == "1"

SECRET_KEY = env("DJANGO_SECRET_KEY", "")
if not SECRET_KEY:
    if DEBUG:
        from django.core.management.utils import get_random_secret_key
        SECRET_KEY = get_random_secret_key()
    else:
        raise ImproperlyConfigured("DJANGO_SECRET_KEY is required when DEBUG is off")

Refusing to boot is the point. A server that starts with a random key silently invalidates every session on each restart. Generate the real value once with get_random_secret_key() or a 64-character string from the password generator, and store it in the environment, never in the repository. If you later need to rotate it without logging everybody out, put the old one in SECRET_KEY_FALLBACKS for a couple of weeks.

2. ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS

ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS if "." in h]

Two things bite here. CSRF_TRUSTED_ORIGINS needs the scheme, not a bare hostname, and a missing entry shows up as a 403 on form submission that looks nothing like a configuration problem. And if your load balancer health-checks by IP, add that IP or your logs will fill with Invalid HTTP_HOST header and you will stop reading them.

3. HTTPS, HSTS and the proxy header

If Django sits behind nginx or a platform router, it sees plain HTTP and thinks the request is insecure, so request.is_secure() is false and SECURE_SSL_REDIRECT loops forever. Tell it how to know:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

if not DEBUG:
    SECURE_SSL_REDIRECT = True
    SESSION_COOKIE_SECURE = CSRF_COOKIE_SECURE = True
    SECURE_HSTS_SECONDS = 31536000
    SECURE_HSTS_INCLUDE_SUBDOMAINS = True
    SECURE_HSTS_PRELOAD = True

X_FRAME_OPTIONS = "DENY"
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"

Only set that header if a proxy you control actually sets X-Forwarded-Proto. If clients can reach Django directly, they can forge it.

HSTS deserves caution, because it is the one setting on this page you cannot take back quickly. Once a browser has seen max-age=31536000, it refuses plain HTTP to your domain for a year, and includeSubDomains extends that to every subdomain including the one still serving a plain-HTTP internal tool. Ramp it: deploy with 300, leave it a day, go to 86400, leave it a week, then a year. Add preload only when you are certain every subdomain is HTTPS, because removal from the preload list takes months.

4. Static files

WhiteNoise lets Django serve its own static files with correct caching headers, which removes an nginx config from the deployment.

STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"
        if not DEBUG else "django.contrib.staticfiles.storage.StaticFilesStorage"
    },
}
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]

The middleware goes directly after SecurityMiddleware, nowhere else:

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    ...
]

CompressedManifestStaticFilesStorage fingerprints filenames and pre-compresses to gzip and brotli, so you can cache aggressively. It also turns a missing file into a hard error at render time: ValueError: Missing staticfiles manifest entry for 'img/logo.svg'. That is the behaviour you want, but it means collectstatic --noinput has to run during the image build and the build has to fail if it fails. Discovering a broken {% static %} path from a 500 on the live site is avoidable.

5. Migrations and backups

Two commands in CI, before anything deploys:

python manage.py makemigrations --check --dry-run   # fails if a model change has no migration
python manage.py migrate --plan                    # shows exactly what will run

Run migrate as a deploy step, not at application startup. With several gunicorn workers booting at once, startup migrations race each other.

For backups, the rule that matters is not the schedule. It is that a backup you have never restored is not a backup, it is a file. Put a restore in the calendar quarterly: pull the dump, load it into a scratch database, run manage.py check and open two pages.

# PostgreSQL
pg_dump -Fc "$DATABASE_URL" > "backup-$(date +%F).dump"
# SQLite, safe while the app is running
sqlite3 db.sqlite3 ".backup 'backup-$(date +%F).sqlite3'"

Never copy a live SQLite file with cp. A write in progress gives you a corrupt copy that looks fine until you need it.

6. manage.py check --deploy

DJANGO_SETTINGS_MODULE=config.settings python manage.py check --deploy --fail-level WARNING

With --fail-level WARNING it returns non-zero, so it can gate a deploy. The warnings you will meet:

Code Meaning
W004 SECURE_HSTS_SECONDS unset
W008 SECURE_SSL_REDIRECT not True
W009 SECRET_KEY too short, too repetitive, or still the generated django-insecure- one
W012 / W016 session or CSRF cookie not marked secure
W018 DEBUG is True
W020 ALLOWED_HOSTS empty

Run it against production settings, not your local ones, or it reports on a configuration nobody will use.

7. Error pages

Add templates/404.html and templates/500.html, and keep 500 nearly inert: plain HTML, an inline style block, no template tags that query the database and no context processor output. A 500 page that itself raises gives the visitor a blank screen and you a confusing log entry. Test both for real with DEBUG = False locally:

DJANGO_DEBUG=0 DJANGO_ALLOWED_HOSTS=127.0.0.1 python manage.py runserver --insecure

--insecure makes runserver serve static files with DEBUG off, which you need or the pages render unstyled and you learn nothing.

8. Sitemap, robots and canonical URLs

Use django.contrib.sitemaps with one sitemap class per section and a sitemap index, so one large section does not push the rest past the 50,000-URL limit. Serve robots.txt from a view rather than a static file, so the Sitemap: line can use the real domain. Pick www or non-www, redirect the other at the edge, and emit <link rel="canonical"> on every page. Two hostnames both answering 200 is the most common self-inflicted indexing problem.

9. Logging

The default configuration writes nothing to disk. Set up console logging that a container runtime will capture, and turn on mail_admins for 500s:

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {"v": {"format": "{asctime} {levelname} {name} {message}", "style": "{"}},
    "handlers": {
        "console": {"class": "logging.StreamHandler", "formatter": "v"},
        "mail_admins": {"class": "django.utils.log.AdminEmailHandler", "level": "ERROR"},
    },
    "root": {"handlers": ["console"], "level": env("LOG_LEVEL", "INFO")},
    "loggers": {
        "django.request": {"handlers": ["console", "mail_admins"], "level": "ERROR",
                           "propagate": False},
        "django.security.DisallowedHost": {"handlers": ["console"], "level": "CRITICAL"},
    },
}

That last logger exists because bots probe your IP with random Host headers all day, and without it those fill your inbox.

10. Email

The default backend in most projects is the console one, which is why "the contact form silently does nothing" is the most common post-launch bug. Set the real backend and prove it from a shell on the server, not from your laptop:

python manage.py shell -c "from django.core.mail import send_mail; \
send_mail('live test', 'ok', None, ['you@example.com'], fail_silently=False)"

fail_silently=False is the part that matters. Also set DEFAULT_FROM_EMAIL and SERVER_EMAIL to an address on a domain with SPF and DKIM records, or transactional mail goes to spam regardless of how correct your Django is.

What breaks first under real traffic

Not security, in my experience. Performance, and always in the same four places.

N+1 queries. A list page that looks instant with 20 rows runs 201 queries with 200 rows. Install django-debug-toolbar locally, load the page, read the query count. Anything over about 15 on a list view needs select_related for foreign keys and prefetch_related for reverse and many-to-many relations.

No connection reuse. Default CONN_MAX_AGE is 0: a fresh database connection per request. On PostgreSQL that handshake dominates a cheap view. Set CONN_MAX_AGE = 60, and on psycopg 3 add the built-in pool:

DATABASES["default"]["CONN_MAX_AGE"] = 60
DATABASES["default"]["OPTIONS"] = {"pool": {"min_size": 2, "max_size": 10}}

A missing index. The column you filter on every single request, usually slug, created_at or a foreign key you query by date. db_index=True plus a migration, and check with .explain() that it is actually used. If reading a query plan is new to you, the indexing section of the SQL track is the shortest way in.

SQLite writes. SQLite reads fine under load and serialises writes. One slow write blocks the others and you get database is locked. WAL mode and a timeout buy some room; more than a handful of concurrent writers means PostgreSQL.

Then two configuration items: gunicorn worker count around 2 × cores + 1 for sync workers, and cache_page on the pages that are the same for everyone. Caching the three heaviest public pages for an hour is usually worth more than a week of query tuning.

Frequently asked questions

Is check --deploy enough on its own? No. It checks settings values. It cannot know whether collectstatic ran, whether your backups restore, or whether a view makes 300 queries. Treat a clean run as the floor.

Do I still need nginx if I use WhiteNoise? Not for static files. You still want something in front for TLS termination, request size limits and rate limiting, but that is often the platform's router rather than an nginx you maintain.

What is a safe HSTS value for a first launch? 300. Confirm nothing broke, then raise it in steps to a year. Skip preload until every subdomain is HTTPS.

Can I launch on SQLite? For a content site with light writes, yes, and plenty do. Move to PostgreSQL before you add anything with concurrent writes, such as a queue, a chat or user-generated uploads.

The Django track at /learn/django/ covers the framework side of this in order, and if you would rather hand the deployment to someone else, that is what Python and Django development is for.

B

Written by Bablu Kumar Jha

Founder, BeeProton AI · Mumbai, Maharashtra, India

Related posts