← All posts

How to Deploy a Django App from git: Settings, Static Files and Migrations

October 25, 2026

TL;DR: Django deploys cleanly from git once four things are true: settings come from environment variables, static files are collected and served by the app, gunicorn listens on 0.0.0.0 and the PORT variable, and migrate runs before the server starts. Most first-deploy failures trace back to one of those, usually ALLOWED_HOSTS or a missing collectstatic.

This guide walks through each step with files you can copy. It assumes Django 5 and Postgres, but almost everything applies to any recent Django version.

What does a deployable Django project look like?

Here’s a minimal layout:

mysite/
├── mysite/
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── blog/              # your apps
├── manage.py
├── requirements.txt
├── scripts/
│   └── start.sh
└── .candorfile

On Candor Deploy, a requirements.txt or pyproject.toml marks the repo as a Python app, and Django is one of the frameworks it recognizes (builds and configuration docs). The build runs on Nixpacks, so you don’t need a Dockerfile unless you want one.

Pin what you deploy:

Django==5.1.*
gunicorn==23.*
psycopg[binary]==3.2.*
dj-database-url==2.*
whitenoise==6.*

How should Django settings work in production?

Read everything that differs between your laptop and the server from the environment. Hard-coded secrets end up in git, and a hard-coded DEBUG = True leaks stack traces to anyone who hits an error.

# mysite/settings.py (excerpt)
import os
import dj_database_url

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "") == "1"

ALLOWED_HOSTS = [
    h.strip() for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h.strip()
]
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]

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

Using os.environ["DJANGO_SECRET_KEY"] with square brackets is deliberate. If the key is missing, the app fails loudly at startup instead of running with an empty secret.

dj-database-url reads DATABASE_URL and turns it into Django’s DATABASES dict. Django’s own Postgres backend accepts the plain postgresql:// scheme, so you don’t need the driver-specific URL that SQLAlchemy projects do.

Why does my Django app return 400 Bad Request after deploying?

Nine times out of ten it’s ALLOWED_HOSTS. With DEBUG off, Django rejects any request whose Host header isn’t on the list, and it answers with a bare 400 that doesn’t say why.

List every hostname the app answers on. On Candor Deploy, each app gets a generated address of the form <service-name>-<account-number>.candorhost.app, shown at the top of the app’s page in the portal, plus any custom domains you add (domains and HTTPS docs). Put them all in one variable:

DJANGO_ALLOWED_HOSTS=shop-42.candorhost.app,www.example.com

The settings excerpt above also builds CSRF_TRUSTED_ORIGINS from the same list. Django 4 and later check the Origin header on form posts, so a missing entry shows up as “CSRF verification failed” the first time you log in to the admin.

How do you serve static files without a separate server?

Django doesn’t serve static files when DEBUG is off. The simplest fix for a small app is WhiteNoise, which serves them from the app process with proper caching headers.

# mysite/settings.py (excerpt)
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",  # right after SecurityMiddleware
    # ...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"},
}

Then run python manage.py collectstatic --noinput before the server starts. If you skip it, the manifest storage raises an error on the first page that references a static file, so you’ll notice quickly.

User uploads (MEDIA_ROOT) are a different story. A container’s filesystem is replaced on every deploy, so anything written to local disk disappears. Send uploads to object storage from the start.

Where do Django migrations run?

In your app’s own startup, before gunicorn begins listening. The Candor Deploy databases docs say it plainly: schema migrations are your app’s job, run them when your app starts, and there’s no separate release-phase hook. The database is also reachable only from your own services, never from the internet, so you can’t run migrate from your laptop against production (databases docs).

One start script handles all three steps:

#!/bin/sh
# scripts/start.sh
set -e

python manage.py migrate --noinput
python manage.py collectstatic --noinput

exec gunicorn mysite.wsgi:application \
  --bind "0.0.0.0:${PORT:-8080}" \
  --workers "${WEB_CONCURRENCY:-2}" \
  --access-logfile -

set -e stops at the first failure, so a broken migration never leads to a half-started app. exec hands the process to gunicorn so it receives shutdown signals directly, and --access-logfile - sends request logs to stdout where you can read them.

The port matters as much as the migrations. On Candor Deploy, your app listens on the port in the PORT environment variable, which defaults to 8080; traffic is routed there and the app is health-checked on it, so an app on a hard-coded port builds and then never comes live (builds and configuration docs). Binding to 0.0.0.0 rather than 127.0.0.1 lets the router reach the process at all.

Keep each migration backward compatible with the code that’s still running during a deploy. Our guide to expand/contract migrations shows how to rename or drop a column without breaking the old version.

How do you tell the platform how to start the app?

Python apps have no universal start convention, so be explicit. On Candor Deploy, a .candorfile at the repo root sets the start command, and it takes priority over anything detected:

services:
  web:
    start: sh scripts/start.sh
    database: postgres
    env:
      DJANGO_SECRET_KEY: { generate: hex32 }

database: postgres provisions a Postgres database and wires its connection string into your environment. { generate: hex32 } mints a random secret on the platform side, so the Django secret key never lives in your repository. If you’d rather create the database in the portal, the Attach a database control on the app’s Environment tab writes the URI into your app as DATABASE_URL; redeploy afterward, since environment changes reach the app on its next deploy.

What else should you check before the first deploy?

  • Workers. Each gunicorn worker is a separate Python process with its own memory. Two is a sensible start on a small plan; raise it only if memory allows.
  • Admin account. Create a superuser through a one-off management command run by the app itself, or a small data migration, since you can’t reach the database from your laptop.
  • Health route. Add a cheap view that returns 200 without touching the database, so you have something to curl after each deploy.
  • Logging. Leave Django’s logging pointed at the console. Anything written to stdout shows up in your runtime logs.

What happens when something goes wrong?

Read the two logs in order. Build output, on the Deployments tab, tells you whether dependencies installed and the stack was detected. Runtime logs, on the Logs tab, show your migration output, the gunicorn banner and any traceback (troubleshooting docs).

The failures you’ll see most:

  1. Stuck in Deploying. Gunicorn isn’t bound to 0.0.0.0:$PORT. Check the --bind flag.
  2. 400 Bad Request on every page. The hostname is missing from DJANGO_ALLOWED_HOSTS.
  3. KeyError: 'DJANGO_SECRET_KEY'. The variable isn’t set yet, or you saved it without redeploying.
  4. Connection refused right after creating the database. A fresh Postgres takes a moment to generate credentials. Redeploy once its Connect tab shows a connection URI.

What should you do next?

Add scripts/start.sh and a .candorfile to your Django repo, run the script once locally with PORT=8080 sh scripts/start.sh, then push. The Candor Deploy quickstart walks through connecting the repo and watching your first build.

Written with help from our AI assistant.