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
- Backwards-compatible migrations (see migrations cheatsheet).
- Health check that lets new pods come up before old ones go down.
gunicorn --preloadto share memory.- Static files via CDN, not the app server.
Common mistakes
DEBUG=Truein prod → information disclosure.- Forgetting
collectstatic→ 404 on /static/. ALLOWED_HOSTS=[]withDEBUG=False→ 400 every request.gunicornwith 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 .