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.
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()produceshttp://links, in emails and redirects alike;- every form POST fails with CSRF verification failed, because the browser sends
Origin: https://…while Django expectshttp://….
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 aSECRET_KEY. The dummy value exists only for this one command and never reaches the running processes.collectstaticdoes not open a database connection, soDATABASE_URLcan stay unset.--bind 0.0.0.0:8080. gunicorn's default is127.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.