Guides · Deployment

Django hosting: deploy Django with PostgreSQL

The settings a Django project needs before it serves real traffic, tested against a fresh startproject, and the Dockerfile Staxa builds for it.

By Jonathan Pitter ·

The short answer

A project made with django-admin startproject isn't ready to serve real traffic. Before deploying it anywhere, change five things:

  1. SECRET_KEY from the environment, and DEBUG off.
  2. ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS set to your domain. With the defaults, every request on a real domain gets 400 Bad Request.
  3. SECURE_PROXY_SSL_HEADER, so Django knows requests came in over HTTPS.
  4. Static files served by WhiteNoise, collected at build time.
  5. The database from DATABASE_URL, with dj-database-url.

Then give the platform a health check it can reach, and run it with gunicorn, not runserver. Everything below was tested on Django 6.1 with PostgreSQL 16: a fresh startproject, the settings shown here, migrations, the admin with its CSS, a request with the wrong Host header (rejected with 400, as it should be) and a health check addressed to the pod's IP.

Production settings

Add these packages to requirements.txt:

django
gunicorn
whitenoise
dj-database-url
psycopg[binary]

Then replace the SECRET_KEY, DEBUG, ALLOWED_HOSTS and DATABASES lines in settings.py with:

import os

import dj_database_url

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

# Your Staxa subdomain, plus any custom domain, comma-separated.
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",")
CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS if host]

# TLS ends at the Staxa router, which sets X-Forwarded-Proto.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

# Staxa sets DATABASE_URL when the tenant has a database.
DATABASES = {"default": dj_database_url.config(conn_max_age=600)}

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

and add WhiteNoise directly after the security middleware (the health check middleware comes from the next section):

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    # ...the rest, unchanged
]

What each one does:

  • SECRET_KEY signs sessions and password reset links. A key committed to the repository is a key anyone with read access can use to forge them. os.environ["SECRET_KEY"] fails loudly at startup if it's missing, which is what you want.
  • ALLOWED_HOSTS is the list of Host headers Django answers. With DEBUG off and an empty list, it answers none; with DEBUG on, only localhost. Either way your real domain gets 400 DisallowedHost.
  • CSRF_TRUSTED_ORIGINS must list the scheme and host of every origin that submits forms over HTTPS. Without it, the admin login fails with "CSRF verification failed".
  • SECURE_PROXY_SSL_HEADER tells Django to trust the proxy's X-Forwarded-Proto header. Without it, request.is_secure() is false behind TLS termination, so secure cookies and SECURE_SSL_REDIRECT misbehave.
  • WhiteNoise serves the files collectstatic gathered, with compression and cache headers, from the same gunicorn process. With DEBUG off Django doesn't serve static files at all, so without it the admin has no CSS.
  • dj-database-url turns DATABASE_URL into Django's DATABASES dictionary. conn_max_age=600 keeps connections open between requests instead of reconnecting each time.

A health check that ALLOWED_HOSTS doesn't block

Kubernetes decides a new container is ready by requesting a path on the pod. Unless the platform sets a Host header on that check, the header is the pod's IP address, which isn't in ALLOWED_HOSTS, so Django answers 400 and the deploy never becomes ready. Staxa sends your service's own address as the Host, so the check passes once that address is in ALLOWED_HOSTS; many other platforms don't. Either way, a health check that skips host validation and the database is the dependable option. Answer it before Django validates the host, with a small middleware at the top of the list:

# mysite/health.py
from django.http import HttpResponse


def health_check(get_response):
    """Answer the platform's readiness check before ALLOWED_HOSTS is applied.

    The check is sent to the pod's IP address, which isn't (and shouldn't be)
    in ALLOWED_HOSTS.
    """

    def middleware(request):
        if request.path == "/healthz":
            return HttpResponse("ok")
        return get_response(request)

    return middleware
MIDDLEWARE = [
    "mysite.health.health_check",
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    # ...
]

We checked it with the pod-style Host header: /healthz returns 200, and every other path still returns 400 for that host. Don't add the pod IP to ALLOWED_HOSTS instead; it changes on every deploy. Then point the health check at /healthz.

Run it with gunicorn

gunicorn mysite.wsgi:application --bind 0.0.0.0:$PORT

mysite is your project package, the folder with settings.py and wsgi.py. gunicorn reads WEB_CONCURRENCY for the worker count; two to four per CPU is a reasonable start. runserver is single-threaded, reloads on changes and isn't written to be exposed to the internet.

Migrations

Run python manage.py migrate --noinput before the new version takes traffic. The simplest reliable place is an entrypoint script in your own Dockerfile that migrates and then starts gunicorn with exec:

#!/bin/sh
set -e
python manage.py migrate --noinput
exec gunicorn mysite.wsgi:application --bind 0.0.0.0:$PORT

This is fine with one instance. With several starting at once, run migrations as a separate release step instead, so two instances don't race to apply the same one.

Deploying Django on Staxa

Connect the GitHub repository. Staxa detects Django from manage.py, reads the settings module from it to find your WSGI module (mysite.wsgi for startproject mysite), and, if the repository has no Dockerfile, generates this one (port 8000, Python 3.14):

FROM python:3.14-slim AS builder
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends gcc libpq-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt* Pipfile* pyproject.toml* ./
RUN if [ -f requirements.txt ]; then pip install --no-cache-dir -r requirements.txt; \
    elif [ -f Pipfile ]; then pip install pipenv && pipenv install --system --deploy; \
    elif [ -f pyproject.toml ]; then pip install --no-cache-dir .; fi
RUN pip show gunicorn >/dev/null 2>&1 || pip install --no-cache-dir gunicorn

FROM python:3.14-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY . .
# The build gets none of the tenant's env vars, so settings that read
# SECRET_KEY from the environment get a placeholder, for this step only.
RUN SECRET_KEY=build-placeholder DJANGO_SECRET_KEY=build-placeholder python manage.py collectstatic --noinput \
    || echo "WARNING: collectstatic failed (error above), so this image has no collected static files. Set STATIC_ROOT, and make sure settings load with no env vars other than SECRET_KEY."
RUN useradd --system --create-home app
USER app
ENV PORT=8000
EXPOSE 8000
CMD ["sh", "-c", "exec gunicorn mysite.wsgi:application --bind 0.0.0.0:$PORT"]

What this means for your project:

  • Static files are collected during the build, when none of your environment variables exist. SECRET_KEY gets a placeholder for that one step, so the settings above load. If collectstatic still fails (no STATIC_ROOT, or settings that need another variable to import), the build log shows the error and a warning, and the image ships without static files.
  • Set SECRET_KEY and ALLOWED_HOSTS in the service's environment. ALLOWED_HOSTS is your service's address, <service>-<tenant>.staxa.app, plus any custom domain.
  • DATABASE_URL is set for you when the tenant has a PostgreSQL or MySQL database, along with DB_HOST, DB_PORT, DB_NAME, DB_USER and DB_PASSWORD. For MySQL, add mysqlclient to your requirements.
  • The generated image doesn't run migrations. Commit a Dockerfile with the entrypoint above when you're ready; the Django and PostgreSQL demo does exactly that. Staxa builds your Dockerfile instead of generating one.
  • Set the health check path. Staxa checks readiness with GET / unless you set a path, sent with your service's address (<service>-<tenant>.staxa.app) as the Host. A project with no view at / answers 404 there, so point the check at /healthz with the middleware above, or at any view that returns 200.

Common problems

The deploy never becomes ready

The readiness check is getting 400 (its Host isn't in ALLOWED_HOSTS) or 404 (nothing at /). Add the health check middleware and point the check at /healthz, and check that your service's address is in ALLOWED_HOSTS.

400 Bad Request on every page

ALLOWED_HOSTS doesn't include the domain in the request. With DEBUG on, the log says Invalid HTTP_HOST header: 'web-acme.staxa.app'. You may need to add 'web-acme.staxa.app' to ALLOWED_HOSTS.

CSRF verification failed on login

The origin isn't in CSRF_TRUSTED_ORIGINS, or SECURE_PROXY_SSL_HEADER is missing so Django thinks the request was plain HTTP.

The admin has no CSS

Static files weren't collected or aren't served. Check that STATIC_ROOT is set, WhiteNoise is in MIDDLEWARE after SecurityMiddleware, and collectstatic succeeded in the build.

ModuleNotFoundError: No module named 'config'

gunicorn was pointed at the wrong WSGI module. Use your project package's name: gunicorn mysite.wsgi:application.

ImproperlyConfigured: settings.DATABASES is improperly configured

DATABASE_URL isn't set where the command runs. That's expected during the image build; at runtime, check the tenant has a database.

Frequently asked questions

Do I need nginx in front of gunicorn?

Not on a platform that already runs a proxy in front of your container. WhiteNoise covers static files; a CDN in front of the whole site covers the rest, if you need one.

Where do uploaded files go?

Not the container's disk, which is replaced on every deploy. Store user uploads in object storage with django-storages.

Should DEBUG ever be on in production?

No. Debug pages show your settings, environment and source code to anyone who triggers an error.

Give every customer their own stack

Staxa provisions an isolated app, database, subdomain and TLS per customer from one API call. The Free plan includes 2 tenants.