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.txtas the install step when there is arequirements.txt, oruv sync --frozen --no-devwhen there is auv.lock..venv/bin/gunicorn mysite.wsgi --bind 127.0.0.1:$PORTas the start command, with the project name read fromDJANGO_SETTINGS_MODULEinmanage.py. It picks uvicorn instead when the project has anasgi.pyand uvicorn is a dependency.manage.py migrate --noinputas the migrate step, run after a snapshot of the database.manage.py collectstatic --noinputas a build command, whensettings.pysetsSTATIC_ROOT.- PostgreSQL, when
psycopg,psycopg2orasyncpgis a dependency, and Redis whenredisorcelery[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.
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.
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.
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.
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
- Set SECRET_KEY and other variables from the dashboard or the CLI.
- Add your own domain with HTTPS: one A record, and Caddy gets the certificate.
- Read and search the app's logs, live or for a time range.
- Roll back a bad deploy to a kept release without a rebuild.
- See the daily PostgreSQL backups and restore one.