2026-03-28 | PreviewProof Team

Preview-Ready Django: A Working Stack with Postgres and Celery

djangopreview environmentscelerypostgresdocker compose

Django previews look easy until you actually run them against an existing codebase. Then you find the gotchas: a migration graph that’s order-sensitive in ways the docs don’t fully explain, Celery workers holding broker connections that need to be scoped per preview, static and media file handling that diverges from local development in subtle ways, and an auth model that assumes a stable hostname for OAuth callbacks.

This post is the configuration reference. The patterns here apply to Django 4.2 LTS and Django 5.x. Most also apply backward to 3.2.

The reference stack

Django 5 with Postgres 16 as the database, Redis 7 as both Celery broker and cache, Celery for background tasks. WhiteNoise for static files (the one decision that pays for itself in preview environments — you’ll see why below).

docker-compose.yml: local development

docker-compose.yml
services:
web:
build: .
command: python manage.py runserver 0.0.0.0:8000
ports:
- "8000:8000"
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/app
REDIS_URL: redis://redis:6379/0
DJANGO_SETTINGS_MODULE: app.settings.dev
volumes:
- .:/code
depends_on:
db: { condition: service_healthy }
redis: { condition: service_started }
worker:
build: .
command: celery -A app worker -l info
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/app
REDIS_URL: redis://redis:6379/0
DJANGO_SETTINGS_MODULE: app.settings.dev
depends_on:
- db
- redis
db:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine

The same image runs both the web service and the Celery worker — common Django convention, simplifies image management.

docker-compose.yml: preview-ready

Once the stack moves to ephemeral previews, the same docker-compose.yml shifts to use built images, env-var-injected hostnames, and preview-specific settings:

docker-compose.yml
services:
web:
image: ghcr.io/myorg/myapp:${GIT_SHA}
command: ./bin/preview-entrypoint.sh
environment:
DJANGO_SETTINGS_MODULE: app.settings.preview
DATABASE_URL: postgres://postgres:postgres@db:5432/app_preview
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${DJANGO_SECRET_KEY}
ALLOWED_HOSTS: ${PREVIEW_HOST}
CSRF_TRUSTED_ORIGINS: https://${PREVIEW_HOST}
OAUTH_CALLBACK_BASE: https://${PREVIEW_HOST}
worker:
image: ghcr.io/myorg/myapp:${GIT_SHA}
command: celery -A app worker -l info --concurrency=2
environment:
DJANGO_SETTINGS_MODULE: app.settings.preview
DATABASE_URL: postgres://postgres:postgres@db:5432/app_preview
REDIS_URL: redis://redis:6379/0

ALLOWED_HOSTS is a frequent Django preview footgun. The dynamic preview hostname must be passed in at runtime; if it isn’t, every request 400s with “Bad Request (400)” and no useful error in the logs unless DEBUG=True. Same for CSRF_TRUSTED_ORIGINS on Django 4+.

A preview settings module

app/settings/preview.py
from .base import * # noqa
import os
DEBUG = False
SECRET_KEY = os.environ["SECRET_KEY"]
ALLOWED_HOSTS = [h.strip() for h in os.environ["ALLOWED_HOSTS"].split(",")]
CSRF_TRUSTED_ORIGINS = [
o.strip() for o in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") if o
]
# WhiteNoise for static; the platform terminates TLS upstream
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
*MIDDLEWARE,
]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Email goes nowhere in previews
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Celery
CELERY_BROKER_URL = os.environ["REDIS_URL"]
CELERY_TASK_ALWAYS_EAGER = False

WhiteNoise is the right call for previews even if you use S3/CloudFront in production. Most preview platforms don’t have a CDN in front of them. WhiteNoise serves static files from inside the Django process, which means python manage.py collectstatic once at boot and you’re done.

The entrypoint

bin/preview-entrypoint.sh
#!/usr/bin/env bash
set -e
echo "==> Migrating"
python manage.py migrate --noinput
echo "==> Collecting static"
python manage.py collectstatic --noinput
echo "==> Seeding"
python manage.py loaddata fixtures/preview_seed.json
python manage.py seed_demo_data
echo "==> Booting gunicorn"
exec gunicorn app.wsgi:application -b 0.0.0.0:8000 -w 3

migrate --noinput is idempotent and the right command for ephemeral environments. The seed step combines a static fixtures file (lookup tables, roles, fixed admin user) with a custom management command that uses factory_boy for richer demo data. See seeding Postgres for preview environments for the deeper pattern.

A working seed_demo_data command:

app/management/commands/seed_demo_data.py
from django.core.management.base import BaseCommand
from app.factories import UserFactory, ProjectFactory
import factory.random
class Command(BaseCommand):
def handle(self, *args, **options):
factory.random.reseed_random(42) # deterministic
admin, _ = UserFactory._meta.model.objects.get_or_create(
defaults={"is_staff": True, "is_superuser": True},
)
admin.set_password("preview123")
admin.save()
for _ in range(20):
UserFactory()
for _ in range(5):
ProjectFactory()

get_or_create patterns make this idempotent even if the entrypoint runs twice.

Migration graph gotchas

Django’s migration system is more brittle than it looks. Two specific failure modes show up in preview environments and not in local development:

Squashed migrations and dependency edges. If you’ve squashed migrations in some apps but not others, Django can pick a different migration path on a fresh database than the one you tested locally. Always run python manage.py migrate --plan against a fresh database before relying on it in CI. If the plan looks weird, your dependency graph has issues.

Data migrations that depend on existing data. A data migration that assumes a row exists in some lookup table will silently no-op on a fresh database — and then the application breaks at runtime. Either make data migrations idempotent (get_or_create, never update) or move the data into a fixture loaded after migrations.

See database migration patterns for preview environments for the broader discussion.

Celery: broker scoping

Celery talks to Redis (or RabbitMQ) as a broker. If two preview environments share a broker, they’ll consume each other’s tasks. Two safe patterns:

  1. Per-preview Redis container. Cheap, isolates completely, what the Compose file above does.
  2. Per-preview database number on a shared Redis. REDIS_URL=redis://shared:6379/${PR_NUMBER} works for a small number of previews; Redis only allocates 16 numbered databases by default, so this caps before it scales.

Reduce concurrency in previews — --concurrency=2 is plenty. Background work in previews exists to verify the feature, not to handle load. See background jobs in ephemeral previews.

Static and media files

Static files: WhiteNoise, as covered above. Media files (user uploads) are trickier. In production you probably write to S3. In a preview environment you have three choices:

  1. Local filesystem in the container. Lost on every preview boot. Fine if media isn’t part of what reviewers verify.
  2. A scoped S3 prefix. MEDIA_ROOT=s3://bucket/previews/${PR_NUMBER}/ works but requires cleanup logic.
  3. MinIO sidecar in Compose. A local S3-compatible store in the preview environment itself. Most realistic.

OAuth and auth callback URLs

Django’s django-allauth, social-auth, and most OAuth integrations want a stable callback URL registered with the provider. Preview environments don’t have stable URLs by default. The fix is the wildcard-callback pattern — register https://*.previews.example.com/auth/callback/ once at the provider level. See OAuth callback URLs in ephemeral previews for the full pattern.

Env template

.env.preview.example
DJANGO_SETTINGS_MODULE=app.settings.preview
SECRET_KEY= # generated per preview
DATABASE_URL= # injected by platform
REDIS_URL= # injected by platform
ALLOWED_HOSTS= # injected: pr-123.previews.example.com
CSRF_TRUSTED_ORIGINS= # injected: https://pr-123.previews.example.com
OAUTH_CALLBACK_BASE= # injected; same as ALLOWED_HOSTS

If you’re adapting an existing Django app, the making a repo preview-ready audit walkthrough maps cleanly onto the gotchas above. The Compose configuration above is portable — it works with whatever preview tool or self-hosted setup you have.

If you’d rather not script the per-PR orchestration yourself, PreviewProof runs Compose-shaped Django stacks like the one above directly, with proper hostname injection so ALLOWED_HOSTS and OAuth callbacks resolve on first boot. It’s most useful for teams where the preview also needs to function as a sign-off and audit surface — the configuration above stays the same; the verification layer is the part that picks up where the config leaves off.