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.

ox reads the ox.toml on this page, makes the plan on the card, and starts .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.txt when there is a requirements.txt, or uv sync --frozen --no-dev when there is a uv.lock.
  • The start command: .venv/bin/gunicorn mysite.wsgi --bind 127.0.0.1:$PORT. ox reads the project name from DJANGO_SETTINGS_MODULE in manage.py. It picks uvicorn instead when the project has an asgi.py (or an asgi/ 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, when settings.py sets STATIC_ROOT. A module of a settings/ package (base.py, production.py) counts too. When django-compressor is a dependency and the settings set COMPRESS_OFFLINE = True, manage.py compress --force runs after it.
  • PostgreSQL when psycopg, psycopg2 or asyncpg is a dependency, and Redis when redis or celery[redis] is.
  • [storage] keep = ["db.sqlite3"] when the settings are the ones startproject writes (SQLite is the one engine, and NAME = 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-dev and libsasl2-dev for python-ldap, libmysqlclient-dev and pkg-config for mysqlclient, and libpq-dev for psycopg2 and psycopg[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.

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 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:

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.

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, 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

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.