Docs menuDeploy Django

Deploy Django to a VPS with gunicorn and Postgres

Deploy Django to your own VPS without Docker: gunicorn, PostgreSQL, migrations, static files served by Caddy, uploads kept across deploys, in one ox.toml.

Last updated 2026-10-08

To deploy Django to a VPS with ox, add the repo as a project, let ox detect gunicorn, the migrate step and collectstatic, set SECRET_KEY, and deploy. ox runs gunicorn as a systemd service behind Caddy, with no Docker and no nginx to configure, and with the ox.toml below Caddy serves your static files too. Migrations run after a database snapshot, and traffic moves to the new release only after its health check answers.

What ox detects in a Django repo

A Django repo often deploys with no ox.toml at all. From the files at the repository root, ox proposes:

  • uv venv --allow-existing && uv pip install -r requirements.txt as the install step when there is a requirements.txt, or uv sync --frozen --no-dev when there is a uv.lock.
  • .venv/bin/gunicorn mysite.wsgi --bind 127.0.0.1:$PORT as the start command, with the project name read from DJANGO_SETTINGS_MODULE in manage.py. It picks uvicorn instead when the project has an asgi.py and uvicorn is a dependency.
  • manage.py migrate --noinput as the migrate step, run after a snapshot of the database.
  • manage.py collectstatic --noinput as a build command, when settings.py sets STATIC_ROOT.
  • PostgreSQL, when psycopg, psycopg2 or asyncpg is a dependency, and Redis when redis or celery[redis] is.

When neither gunicorn nor uvicorn is a dependency, ox check says Django found, but neither gunicorn nor uvicorn is a dependency; add one or set [app] start. Add gunicorn to your requirements to fix it.

The ox.toml for Django

Write one when you want a domain, a health check, a cron job or uploads that survive deploys. Once a repo has an ox.toml, its services are exactly the ones it lists, so postgres is declared here.

ox.toml for a Django project named mysite
domains = ["example.com"]

[app]
start  = ".venv/bin/gunicorn mysite.wsgi --bind 127.0.0.1:$PORT --workers 2"
health = "/healthz"

[static]
paths = { "/static" = "staticfiles" }

[build]
install  = "uv venv --allow-existing && uv pip install -r requirements.txt"
commands = [".venv/bin/python manage.py collectstatic --noinput"]
migrate  = ".venv/bin/python manage.py migrate --noinput"

[cron]
clearsessions = { schedule = "@daily", run = ".venv/bin/python manage.py clearsessions" }

[services]
postgres = {}

[storage]
keep = ["media"]

Caddy serves /static straight from staticfiles ([static] paths), so whitenoise is not needed. [storage] keep links media to a writable folder that survives deploys, because a release's own files are read-only while it runs.

Django settings and variables for production

ox provides DATABASE_URL, PUBLIC_URL and PUBLIC_HOST, so settings.py reads them from the environment.

settings.py
import os
import dj_database_url

SECRET_KEY = os.environ["SECRET_KEY"]
DEBUG = False
ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]
DATABASES = {"default": dj_database_url.config()}

STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
MEDIA_ROOT = BASE_DIR / "media"

You set SECRET_KEY on the dashboard or with ox vars set <project> SECRET_KEY, which asks for the value. ALLOWED_HOSTS can point at ox's value as ${PUBLIC_HOST}. Keys listed in .env.example are required, and a deploy waits until each one is set.

Add a health check view

ox sends traffic to a new release only after /healthz answers with a 2xx or 3xx status. A view that answers without touching the database keeps the check fast.

urls.py, next to your other paths
from django.http import HttpResponse
from django.urls import path

urlpatterns = [
    path("healthz", lambda request: HttpResponse("ok")),
]

Check and deploy the Django app

Run ox check in the repo. For the files above it prints the plan, then the variables to set, then Ready to deploy.

ox check, last lines
  Provided by ox: PORT, HOST, OX_ENV, OX_PROJECT, OX_RELEASE, OX_DATA_DIR, PUBLIC_URL, PUBLIC_HOST, DATABASE_URL
  Set on the dashboard before the first deploy: SECRET_KEY, ALLOWED_HOSTS

Ready to deploy.

Then deploy with ox deploy <project> --wait. If the health check fails, Troubleshooting explains the message.

Next steps