Deploy Django with PostgreSQL: static files, migrations and HTTPS

April 18, 2026
Tutorial

A Django project needs three things on any platform: static files collected somewhere, migrations applied before the new code serves traffic, and a correct idea of whether a request came in over HTTPS. On JetDeploy each of these belongs to a specific step of the deploy, and putting one in the wrong step is the most common cause of a failed first deploy.

1 · Buildcollectstaticno App env varsSECRET_KEY=build-only2 · Pre-deploymigrate --noinputApp env varsown container, files discarded3 · Rolloutgunicorn :8080App env varstraffic after the port check
Where each Django step runs, and which of them see the App environment variables.

What you need

  • A Django project. The examples use Django 6.1 and a project called mysite.
  • A JetDeploy App (see Deploy a Vite single-page app with nginx for the create-and-push basics).
  • A PostgreSQL data service, created from the Console. It is ready in about a minute.

1. Dependencies

Django==6.1.1
gunicorn==26.2.0
psycopg[binary]==3.3.6
whitenoise==6.12.0
dj-database-url==3.1.2

WhiteNoise serves the collected static files from the same gunicorn process, so there is no second web server to run. dj-database-url turns one DATABASE_URL variable into Django's DATABASES setting.

2. Settings that read the environment

import os
from pathlib import Path

import dj_database_url

BASE_DIR = Path(__file__).resolve().parent.parent

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

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

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

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

Add "whitenoise.middleware.WhiteNoiseMiddleware" to MIDDLEWARE, right after SecurityMiddleware.

Why SECURE_PROXY_SSL_HEADER

The JetDeploy edge terminates HTTPS and forwards plain HTTP to your process. Without this setting, request.is_secure() is False on every request, and two things break:

  • request.build_absolute_uri() produces http:// links, in emails and redirects alike;
  • every form POST fails with CSRF verification failed, because the browser sends Origin: https://… while Django expects http://….

The edge replaces X-Forwarded-Proto on every request, so a client cannot spoof it. Trusting that header is safe here.

Leave SECURE_SSL_REDIRECT off. The edge already redirects plain HTTP to HTTPS. With the setting on, Django also redirects the plain HTTP calls that your other Apps make to this one on its internal address, which carry no X-Forwarded-Proto.

3. The Dockerfile

FROM python:3.14-slim

ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app

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

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

RUN useradd --create-home app
USER app

CMD ["gunicorn", "mysite.wsgi", "--bind", "0.0.0.0:8080", "--workers", "2"]

Two lines matter here:

  • SECRET_KEY=build-only. The build does not see the App's environment variables, and the settings above refuse to load without a SECRET_KEY. The dummy value exists only for this one command and never reaches the running processes. collectstatic does not open a database connection, so DATABASE_URL can stay unset.
  • --bind 0.0.0.0:8080. gunicorn's default is 127.0.0.1:8000, which nothing outside the container can reach. JetDeploy waits for the container port to accept connections before sending traffic.

collectstatic runs in the build, not in the pre-deploy script, because the pre-deploy script runs in a container of its own: files it writes never reach the processes.

4. Environment variables

Open the PostgreSQL service page and copy its internal endpoint, username, password and database name. JetDeploy does not inject them into the App, so compose the URL yourself:

DATABASE_URL=postgresql://app_user:Q7xk2mVb9TzP@my-db:5432/app
SECRET_KEY=<50 random characters>
ALLOWED_HOSTS=my-django.jetdeploy.app

A generated password contains only letters and digits, so it needs no percent-encoding inside the URL. Traffic between the App and the service stays on the organization's private network.

When you attach a custom domain later, add it to ALLOWED_HOSTS too.

5. The pre-deploy script

In the App settings, set the pre-deploy script:

python manage.py migrate --noinput

It runs after the build and before the new version receives traffic, in the new image and with the App's environment variables. If it fails, the deploy stops and the previous version keeps serving.

6. The process and the first deploy

Field Value
Name web
Command empty, the Dockerfile CMD is used
Container port 8080
External-facing on

Push the branch, then click Deploy if the push came before the process. The deploy logs show the build output, then the pre-deploy output with the applied migrations, then the outcome line of the deploy.

Checking it

These are the checks we ran against the image with a local PostgreSQL 18:

# migrations apply, and a second run is a no-op
python manage.py migrate --noinput   # Applying ... OK
python manage.py migrate --noinput   # No migrations to apply.

# behind the edge, cookies are marked Secure
curl -sD - -o /dev/null -H 'Host: my-django.jetdeploy.app' \
  -H 'X-Forwarded-Proto: https' http://localhost:8080/admin/login/ | grep -i set-cookie
# Set-Cookie: csrftoken=...; SameSite=Lax; Secure

# the admin login form posts back: 200 with the setting, 403 (CSRF) without it

# an unknown Host header is refused
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: evil.example' http://localhost:8080/admin/login/
# 400

python manage.py check --deploy is worth running once too. It will point out SECURE_SSL_REDIRECT, which you can ignore for the reason above.

Next step

Once the App has real data, schema changes need more care than a plain migrate, because the old version keeps serving while the new one starts. The pre-deploy script section of the docs explains the time limits and what happens when a migration is interrupted.

Tested locally with Django 6.1.1, gunicorn 26.2, Python 3.14 and PostgreSQL 18 (Docker build, migrate, the requests above). The JetDeploy steps follow the documentation.

Related Articles

All posts