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-16
On this page
To deploy Django to a VPS with ox, add the repo as a project, set SECRET_KEY, and deploy. ox detects gunicorn, the migrate step and collectstatic for you. gunicorn runs as a systemd service behind Caddy, so there is no Docker and no nginx to configure. With the ox.toml below, Caddy also serves your static files. Each deploy takes a database snapshot before it migrates, and the new release gets traffic only after its health check answers.
.venv/bin/gunicorn mysite.wsgi --bind 127.0.0.1:$PORT --workers 2 on your server. The cylinder is the service the file asks for: postgres.What ox detects in a Django repo
A Django repo often deploys with no ox.toml at all. ox reads the files at the repository root and proposes:
- The install step:
uv venv --allow-existing && uv pip install -r requirements.txtwhen there is arequirements.txt, oruv sync --frozen --no-devwhen there is auv.lock. - The start command:
.venv/bin/gunicorn mysite.wsgi --bind 127.0.0.1:$PORT. ox reads the project name fromDJANGO_SETTINGS_MODULEinmanage.py. It picks uvicorn instead when the project has anasgi.py(or anasgi/package) and uvicorn is a dependency. - The migrate step:
manage.py migrate --noinput, run after a snapshot of the database. - A build command:
manage.py collectstatic --noinput, whensettings.pysetsSTATIC_ROOT. A module of asettings/package (base.py,production.py) counts too. When django-compressor is a dependency and the settings setCOMPRESS_OFFLINE = True,manage.py compress --forceruns after it. - PostgreSQL when
psycopg,psycopg2orasyncpgis a dependency, and Redis whenredisorcelery[redis]is. [storage] keep = ["db.sqlite3"]when the settings are the onesstartprojectwrites (SQLite is the one engine, andNAME = BASE_DIR / "db.sqlite3"). Every release then uses the same database file in the project's data folder.- The headers a source-only package builds against, when the install needs it:
libldap-devandlibsasl2-devforpython-ldap,libmysqlclient-devandpkg-configformysqlclient, andlibpq-devforpsycopg2andpsycopg[c]. The C compilers are on the server for every Python app.
When none of gunicorn, uvicorn and uwsgi is a dependency, ox check says Django found, but none of gunicorn, uvicorn or uwsgi is a dependency; add one or set [app] start. Add gunicorn to your requirements to fix it.
Details
Your manage.py, wsgi.py or asgi.py may default DJANGO_SETTINGS_MODULE to a development module, such as bakerydemo's dev or cookiecutter-django's local. When settings/production.py also exists, ox check and the deploy log say which module the app runs, and suggest you set DJANGO_SETTINGS_MODULE=<project>.settings.production. This is a hint. A deploy does not wait for it, and it goes away once you set the key.
If the repo commits a db.sqlite3, ox copies it into the kept file on the first deploy only. A later commit of the file never replaces the rows there, and the deploy log says so.
When uwsgi is the only server, ox starts .venv/bin/uwsgi --http-socket 127.0.0.1:$PORT --module mysite.wsgi:application --master --threads 4 --need-app --lazy-apps --hook-master-start 'unix_signal:15 gracefully_kill_them_all'. It does not read your uwsgi.ini, because its pidfile and stats port cannot run under ox's service, and ox check says so. Put the static-map folders it served under [static] paths. To run uwsgi your own way, set [app] start.
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, ox runs only the services it lists, so this file declares postgres.
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 you do not need WhiteNoise. Each deploy starts a new release from the commit. [storage] keep links media to a writable folder that survives deploys, so uploads stay.
Django settings and variables for production
ox provides DATABASE_URL, PUBLIC_URL and PUBLIC_HOST. Read them from the environment in 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"Set SECRET_KEY on the dashboard, or run ox vars set <project> SECRET_KEY, which asks for the value. ALLOWED_HOSTS can point at ox's value: set it to ${PUBLIC_HOST}. Some keys in .env.example need your answer: an empty value, a secret, or a placeholder. A deploy waits until each of those is set.
Add a health check view
ox sends traffic to a new release only after /healthz answers with a 2xx or 3xx status. Keep the view fast by not touching the database.
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, PUBLIC_SCHEME, PUBLIC_PORT, 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.
New to running your own server? How ox runs your apps gives the overview, and the blog's deploy guides walk through a setup by hand.