Django deployment cheatsheet.

Production checklist

uv run python manage.py check --deploy

Reports security/config issues.

settings for production

DEBUG = False
ALLOWED_HOSTS = ["example.com", ".example.com"]
SECRET_KEY = os.environ["SECRET_KEY"]

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "same-origin"
X_FRAME_OPTIONS = "DENY"

DATABASES = {
    "default": dj_database_url.parse(os.environ["DATABASE_URL"]),
}

STATIC_ROOT = BASE_DIR / "staticfiles"
STATIC_URL = "/static/"
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"

gunicorn (sync)

uv add gunicorn
uv run gunicorn config.wsgi:application \
  --bind 0.0.0.0:8000 \
  --workers 4 \
  --threads 2 \
  --timeout 60 \
  --access-logfile - \
  --error-logfile -

Workers: 2 * cpus + 1. Threads: 2-4 if mostly I/O bound.

uvicorn (async)

uv add uvicorn
uv run uvicorn config.asgi:application \
  --host 0.0.0.0 --port 8000 \
  --workers 4

For async views, ASGI middleware, Channels.

Whitenoise (static files)

uv add whitenoise
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",      # right after SecurityMiddleware
    ...,
]

Serves staticfiles/ directly. Good for simpler deployments without nginx.

collectstatic

uv run python manage.py collectstatic --noinput

Run during build/deploy.

Dockerfile

FROM python:3.13-slim AS builder
WORKDIR /app
RUN pip install uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

FROM python:3.13-slim
WORKDIR /app
RUN apt-get update && apt-get install -y libpq5 && rm -rf /var/lib/apt/lists/*
RUN useradd -u 1000 -m app

COPY --from=builder --chown=app:app /app/.venv ./.venv
COPY --chown=app:app . .

ENV PATH=/app/.venv/bin:$PATH
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1

USER app
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]

docker-compose (dev)

services:
  web:
    build: .
    ports: ["8000:8000"]
    environment:
      DATABASE_URL: postgres://postgres:x@db:5432/myapp
      SECRET_KEY: dev
      DEBUG: "1"
    volumes:
      - .:/app
    depends_on: [db, redis]
  
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: x
      POSTGRES_DB: myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data
  
  redis:
    image: redis:7-alpine
  
  worker:
    build: .
    command: celery -A config worker -l info
    environment:
      DATABASE_URL: postgres://postgres:x@db:5432/myapp
    depends_on: [db, redis]

volumes:
  postgres_data:

nginx (reverse proxy)

upstream django {
    server web:8000;
}

server {
    listen 80;
    server_name example.com;
    
    location /static/ { alias /app/staticfiles/; }
    location /media/ { alias /app/media/; }
    
    location / {
        proxy_pass http://django;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Healthcheck endpoint

# urls.py
from django.http import JsonResponse

def health(request):
    from django.db import connection
    try:
        connection.ensure_connection()
        return JsonResponse({"ok": True})
    except Exception:
        return JsonResponse({"ok": False}, status=503)

urlpatterns = [path("health/", health), ...]
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
    CMD curl -fsS http://localhost:8000/health/ || exit 1

Logging

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "json": {
            "()": "pythonjsonlogger.json.JsonFormatter",
            "format": "%(asctime)s %(levelname)s %(name)s %(message)s",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "json",
        },
    },
    "loggers": {
        "django": {"handlers": ["console"], "level": "INFO"},
    },
}

Sentry

uv add sentry-sdk
import sentry_sdk
sentry_sdk.init(
    dsn=os.environ["SENTRY_DSN"],
    traces_sample_rate=0.1,
    environment=os.environ.get("ENV", "production"),
    integrations=[DjangoIntegration(), CeleryIntegration()],
)

Environment via django-environ

uv add django-environ
import environ

env = environ.Env(DEBUG=(bool, False))
environ.Env.read_env(BASE_DIR / ".env")

DEBUG = env("DEBUG")
SECRET_KEY = env("SECRET_KEY")
DATABASES = {"default": env.db()}
CACHES = {"default": env.cache()}

Database migration on deploy

uv run python manage.py migrate --noinput

Run from one container only (use init container in K8s, or pre-deploy step in Vercel/Heroku).

Zero-downtime tips

  1. Backwards-compatible migrations (see migrations cheatsheet).
  2. Health check that lets new pods come up before old ones go down.
  3. gunicorn --preload to share memory.
  4. Static files via CDN, not the app server.

Common mistakes

  • DEBUG=True in prod → information disclosure.
  • Forgetting collectstatic → 404 on /static/.
  • ALLOWED_HOSTS=[] with DEBUG=False → 400 every request.
  • gunicorn with too many workers → OOM.
  • Running migrations from every pod → race conditions.

Read this next

If you want my Django Docker + nginx stack, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .