Back to Blog
Tutorial6 min read2026-07-11

How to Deploy Django with PostgreSQL on PandaStack

Take Django from runserver to production: gunicorn, WhiteNoise static files, dj-database-url, safe migrations, and a Git-push deploy with managed Postgres.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Django's development server is explicitly not for production — the docs say so, and they mean it. Going to production means swapping in a real WSGI server, sorting out static files, hardening settings, and getting migrations to run at the right moment. Django has sharp, well-documented answers for all of these; the work is knowing which knobs to turn. Here's the complete list, in order.

Production settings: the four that matter

Every Django deployment failure I've debugged traces back to one of these four settings. Read them from the environment so the same codebase runs everywhere:

# config/settings.py
import os

SECRET_KEY = os.environ["SECRET_KEY"]  # crash if unset — correct behavior
DEBUG = os.environ.get("DEBUG", "") == "1"

ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",")
CSRF_TRUSTED_ORIGINS = [
    o for o in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") if o
]

DEBUG = False is non-negotiable — with it on, Django serves full tracebacks including settings values to anyone who triggers an error. ALLOWED_HOSTS must contain your domain or Django returns 400 for every request, which is the classic "it deployed but nothing works" symptom. CSRF_TRUSTED_ORIGINS needs the full scheme (https://app.example.com) or every POST from a form fails with a CSRF error — this one bites people upgrading from older Django versions, since it became strict in Django 4.0.

Generate the secret key once and store it as an environment variable:

python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Database: one URL instead of five settings

Django's default DATABASES dict wants host, port, name, user, and password separately. Managed platforms give you a single DATABASE_URL. The dj-database-url package bridges the two:

pip install dj-database-url "psycopg[binary]"
# config/settings.py
import dj_database_url

DATABASES = {
    "default": dj_database_url.config(
        conn_max_age=600,
        conn_health_checks=True,
    )
}

dj_database_url.config() reads DATABASE_URL from the environment automatically. conn_max_age=600 enables persistent connections — without it Django opens and closes a database connection per request, which wastes latency and burns through connection limits (managed databases enforce them; on PandaStack it's 50 connections on the free tier, 300 on Pro). conn_health_checks=True makes Django verify a pooled connection is alive before reusing it, which absorbs database restarts gracefully.

Static files with WhiteNoise

In development, runserver serves static files for you. In production it doesn't, and the traditional answer — a separate nginx in front — is more machinery than most apps need. WhiteNoise serves static files from the Django process itself, with proper caching headers:

pip install whitenoise
# config/settings.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",  # directly after SecurityMiddleware
    # ... the rest unchanged
]

STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

STORAGES = {
    "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
    },
}

The CompressedManifestStaticFilesStorage backend fingerprints every file (app.8e3f2c1a.css), so you can cache them forever and cache-bust automatically on deploy. Then collection happens at build time:

python manage.py collectstatic --noinput

One gotcha: collectstatic imports your settings, and your settings crash without SECRET_KEY. At build time no real secrets exist yet, so give the build a dummy value — SECRET_KEY=build-placeholder python manage.py collectstatic --noinput — or default it only when DEBUG is on. The real key is injected at runtime.

The production server: gunicorn

pip install gunicorn
gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 2

Replace config with your project's package name (the directory containing wsgi.py). Bind to 0.0.0.0, not the default localhost, so the platform's router can reach the process. Worker count follows the usual rule of thumb — 2 × cores + 1 — but on a small container start with 2 and let horizontal replicas do the scaling.

Migrations: run them as a release step

The command is the one you already know:

python manage.py migrate --noinput

Where you run it is what matters. Don't run migrations in the container's entrypoint before the server starts: when two replicas boot simultaneously, both try to apply the same migration and one deadlocks or errors. Run migrate once, as a discrete release step after the image builds and before new pods take traffic.

Also worth internalizing for zero-downtime deploys: for a moment during every rollout, old code runs against the new schema. Additive migrations (new nullable columns, new tables) are safe. Dropping or renaming a column the old code still reads is not — split those across two releases.

The Dockerfile

FROM python:3.12-slim
WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .
RUN SECRET_KEY=build-placeholder python manage.py collectstatic --noinput

RUN useradd --create-home appuser
USER appuser

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

PYTHONUNBUFFERED=1 keeps logs flowing in real time instead of arriving in buffered chunks — you'll want that when you're watching a deploy. Copying requirements.txt before the app code means dependency installation stays cached between builds unless requirements actually change.

Deploying on PandaStack

With the pieces above in place, the platform side is short:

  1. 1Provision a managed PostgreSQL instance (14.x or 16.x — MySQL 8.x works too if that's your stack) from the [dashboard](https://dashboard.pandastack.io).
  2. 2Connect your repository as a container app. With a Dockerfile present it's used directly; without one, the Python buildpack auto-detects the app, and you can override the detected build and start commands if needed.
  3. 3Attach the database to the app. DATABASE_URL lands in the environment automatically, and dj_database_url.config() picks it up with no further wiring.
  4. 4Set SECRET_KEY, ALLOWED_HOSTS, and CSRF_TRUSTED_ORIGINS as environment variables on the app. When you add a custom domain (SSL is automatic), update both of the latter to match.
  5. 5Push. The build runs in an isolated rootless BuildKit job, build logs stream live, and the app goes out via Helm. Run migrate as a release step, and you're serving traffic.

From then on, deploys are git push. Deployment history is kept (10 days on the free tier, up to 90 on Premium), so rolling back a bad release is a dashboard action rather than an archaeology project.

A fair warning that applies to any free tier including ours: free-tier apps scale to zero when idle, so the first request after a lull eats a cold start, and the bundled database volume is sized for development, not a production dataset. Fine for a staging environment or a side project; move to a paid tier when real users show up.

The checklist

  • DEBUG=0, SECRET_KEY from env, ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS set with your real domain
  • dj-database-url + conn_max_age for persistent connections
  • WhiteNoise with the manifest storage backend, collectstatic at build time
  • gunicorn bound to 0.0.0.0, sensible worker count
  • migrate --noinput as a release step, never in the entrypoint
  • Additive-only migrations during rollouts

Django rewards this kind of setup discipline — once it's in place, deploys become boring, which is the goal. If you want to walk through it with the database wired in automatically, try it on https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also