Django Branch Previews: 4 Settings That Break Them
You push a branch, the build goes green, and the preview URL returns DisallowedHost. Or it loads, but every form submission fails with a 403. Or the page renders with no CSS at all.
None of these are preview-tool bugs. They're four Django settings that assume one hostname, one database, and a DEBUG=True server that quietly does work you never configured. A preview environment breaks all four assumptions at once.
This guide walks through each one, shows the exact error you'll see, and gives you the fix. Every snippet is copy-pasteable into a real settings.py.
Before you start: what a preview environment actually is
A preview environment is a running copy of your app, built from one branch, reachable at its own HTTPS URL, and destroyed when you're done with it. For a Django project that means a real container running gunicorn (not runserver), a real database, and a hostname you didn't have yesterday.
If your team currently reviews Django changes by pulling the branch and running python manage.py runserver on a laptop, you already know the cost. The reviewer needs your .env, your database, and your seed data. Half the time they get a different result because their local database has different rows.
The fix is a URL per branch. Getting there is mostly about teaching settings.py that it can live at more than one address.
Setting 1: ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS for preview URLs
This is the first thing that breaks, and it breaks loudly.
Invalid HTTP_HOST header: 'prv-8c1f2a.previewdrop.dev'. You may need to add
'prv-8c1f2a.previewdrop.dev' to ALLOWED_HOSTS.
Django's ALLOWED_HOSTS is a security control, and it's doing its job. The problem is that a preview hostname is generated at deploy time, so you can't hardcode it.
The fix is to read hosts from the environment, with a sane fallback for local development:
# settings.py
import os
def env_list(name: str, default: str = "") -> list[str]:
raw = os.environ.get(name, default)
return [item.strip() for item in raw.split(",") if item.strip()]
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
# Django 4.0+ requires the scheme here. A bare hostname is silently ignored,
# which is why CSRF failures look like they come out of nowhere.
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")
Then set both variables in your preview environment's configuration:
DJANGO_ALLOWED_HOSTS=.previews.previewdrop.dev,localhost
DJANGO_CSRF_TRUSTED_ORIGINS=https://prv-8c1f2a.previewdrop.dev
A leading dot in ALLOWED_HOSTS is a subdomain wildcard, so .previews.previewdrop.dev covers every preview URL for that project. CSRF_TRUSTED_ORIGINS does not accept wildcards — it needs the exact origin, scheme included. That asymmetry catches people every time.
If your preview hostnames are stable per project, the wildcard entry means you set ALLOWED_HOSTS once. If you use a unique hostname per preview, set the full hostname at deploy time from your platform's environment.
One more trap: CSRF_TRUSTED_ORIGINS was added in Django 4.0. If you're still on 3.2, the setting is CSRF_TRUSTED_ORIGINS-less and you'll need USE_X_FORWARDED_HOST = True plus a correct SECURE_PROXY_SSL_HEADER. Upgrading is the better answer.
Setting 2: static files under gunicorn
The preview loads. The HTML is there. The CSS is a 404.
runserver serves static files automatically when DEBUG=True. gunicorn does not, and every preview environment runs a real WSGI server. So the moment DEBUG=False, your stylesheet and admin assets disappear.
You have two options. The first is WhiteNoise, which serves static files from the same process and needs no separate web server:
pip install whitenoise
# settings.py — middleware order matters: WhiteNoise goes directly after
# SecurityMiddleware and before everything else.
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
# ... the rest of your middleware
]
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
The STORAGES dict replaces the old STATICFILES_STORAGE setting, which is deprecated in Django 4.2 and removed in 5.1. If you copy a tutorial written before 2024, you'll get the deprecated form.
Then make sure your build runs collectstatic:
python manage.py collectstatic --noinput
If you use CompressedManifestStaticFilesStorage, a template that references a missing file raises ValueError: Missing staticfiles manifest entry. That's a feature — it fails the build instead of shipping a broken page. But it means every preview build must run collectstatic before gunicorn starts.
The second option is serving static files from a CDN or object store. That's the right call for production and overkill for a preview. Previews are short-lived; WhiteNoise keeps the whole thing in one container.
Setting 3: migrations on every preview
A preview with an empty database is useless. A preview that shares the production database is dangerous.
The pattern that works: each preview runs migrations against its own database on startup, then seeds whatever it needs.
#!/usr/bin/env bash
# bin/preview-start — the container's start command
set -euo pipefail
python manage.py migrate --noinput
python manage.py loaddata preview_seed.json # optional, idempotent fixtures
exec gunicorn myproject.wsgi:application --bind "0.0.0.0:${PORT:-8000}" --workers 2
Two things to get right here.
Use exec. Without it, gunicorn runs as a child of the shell, and the container's stop signal goes to the shell instead of the app. Your preview then takes the full timeout to die instead of shutting down cleanly.
Make the seed idempotent. loaddata with fixed primary keys will fail on a second run with a duplicate-key error. Either use natural keys, or wrap the seed in a check:
# management/commands/seed_preview.py
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
class Command(BaseCommand):
help = "Seed a preview database with synthetic data. Safe to re-run."
def handle(self, *args, **options):
User = get_user_model()
user, created = User.objects.get_or_create(
username="preview-reviewer",
defaults={"email": "reviewer@example.com"},
)
if created:
user.set_unusable_password()
user.save()
self.stdout.write(f"preview user ready (created={created})")
Never point a preview at your staging database and call it done. Staging data drifts, contains real customer rows, and a preview that writes to it can corrupt the environment your team uses for pre-release testing.
Setting 4: environment variables and the PORT contract
Django reads configuration from the environment, and a preview environment has a different environment than your laptop. The failure mode is silent: a missing SECRET_KEY falls back to a hardcoded default in settings.py, the app boots, and sessions behave strangely.
Make the missing-variable failure loud:
# settings.py
def require_env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
SECRET_KEY = require_env("DJANGO_SECRET_KEY")
DATABASE_URL = require_env("DATABASE_URL")
The container crashes on boot with a clear message instead of running half-configured. In a preview, a crash you can read in the logs beats a working-looking app with broken sessions.
Then there's PORT. PreviewDrop sets PORT, HOST and HOSTNAME in the container — nothing else. Your app has to bind to $PORT:
# settings.py — only if you read PORT yourself
import os
PORT = int(os.environ.get("PORT", "8000"))
and your start command has to pass it through to gunicorn, which is what the --bind "0.0.0.0:${PORT:-8000}" flag above does. Bind to 0.0.0.0, not 127.0.0.1 — a container listening only on loopback is unreachable from the router, and the health check fails with a connection refused that looks like a crash.
There is no PREVIEW_URL variable injected into your container. If your app needs to know its own URL — for building absolute links in emails, say — read it from the request (request.build_absolute_uri()) rather than from the environment.
The whole settings block, together
Here's the complete set of changes in one place:
# settings.py — preview-safe configuration
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
def env_list(name: str, default: str = "") -> list[str]:
return [i.strip() for i in os.environ.get(name, default).split(",") if i.strip()]
def require_env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
SECRET_KEY = require_env("DJANGO_SECRET_KEY")
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
# ...
]
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
That's the whole job. Four settings, one start script, and a collectstatic in the build.
What you get when it works
Once these four are in place, a Django preview environment stops being a maintenance project. Push a branch, get a URL. The reviewer clicks it, sees the actual change running against a real database, and leaves a comment. No pip install, no .env file passed around, no "works on my machine."
The setup cost is one afternoon. The return is every future PR review — and every time a designer or PM needs to look at a change without asking an engineer to run it locally.
If you'd rather not maintain the container plumbing yourself, PreviewDrop detects a Django project from requirements.txt or pyproject.toml, builds the image, injects your environment variables, and gives every branch a live HTTPS URL that expires on its own. You keep the four settings above; the platform handles the rest.
Set up your first Django preview →
Or read the quickstart if you want to see the whole flow end to end before connecting a repo.
Want previews on your own pull requests? Start free — 2 concurrent previews, no credit card.