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:
SECRET_KEYfrom the environment, andDEBUGoff.ALLOWED_HOSTSandCSRF_TRUSTED_ORIGINSset to your domain. With the defaults, every request on a real domain gets 400 Bad Request.SECURE_PROXY_SSL_HEADER, so Django knows requests came in over HTTPS.- Static files served by WhiteNoise, collected at build time.
- 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_KEYsigns 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_HOSTSis the list ofHostheaders Django answers. WithDEBUGoff and an empty list, it answers none; withDEBUGon, only localhost. Either way your real domain gets400 DisallowedHost.CSRF_TRUSTED_ORIGINSmust 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_HEADERtells Django to trust the proxy'sX-Forwarded-Protoheader. Without it,request.is_secure()is false behind TLS termination, so secure cookies andSECURE_SSL_REDIRECTmisbehave.- WhiteNoise serves the files
collectstaticgathered, with compression and cache headers, from the same gunicorn process. WithDEBUGoff Django doesn't serve static files at all, so without it the admin has no CSS. - dj-database-url turns
DATABASE_URLinto Django'sDATABASESdictionary.conn_max_age=600keeps 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_KEYgets a placeholder for that one step, so the settings above load. Ifcollectstaticstill fails (noSTATIC_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_KEYandALLOWED_HOSTSin the service's environment.ALLOWED_HOSTSis your service's address,<service>-<tenant>.staxa.app, plus any custom domain. DATABASE_URLis set for you when the tenant has a PostgreSQL or MySQL database, along withDB_HOST,DB_PORT,DB_NAME,DB_USERandDB_PASSWORD. For MySQL, addmysqlclientto 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 theHost. A project with no view at/answers 404 there, so point the check at/healthzwith 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.