# 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. 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. 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. 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` (or an `asgi/` package) 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`, or a module of a `settings/` package (`base.py`, `production.py`), sets `STATIC_ROOT`, then `manage.py compress --force` when django-compressor is a dependency and the settings set `COMPRESS_OFFLINE = True`. - A hint about the production settings: when `manage.py`, `wsgi.py` or `asgi.py` defaults `DJANGO_SETTINGS_MODULE` to a module of the settings package other than `production` (bakerydemo's `dev`, cookiecutter-django's `local`) and `settings/production.py` exists, `ox check` and the deploy log say the app runs that module and name `DJANGO_SETTINGS_MODULE=.settings.production` to set. It is a hint, not a required key, and it goes once the key is set. - The headers a source-only package builds against, when the install reaches it: `libldap-dev` and `libsasl2-dev` for `python-ldap`, `libmysqlclient-dev` and `pkg-config` for `mysqlclient`, `libpq-dev` for `psycopg2` and `psycopg[c]`. The C compilers are on the server for every Python app. - 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 the one engine, `NAME = BASE_DIR / "db.sqlite3"`), so every release starts on the same rows in the project's data folder. A `db.sqlite3` the repo commits fills that kept file on the first deploy only: a later commit of it 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; `ox check` says so, and the `static-map` folders it served go under `[static] paths`. To run uwsgi your own way, set `[app] start`. 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. ## 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: ```toml 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](https://console.t79.sorv.dev/docs/config#static)), so WhiteNoise is not needed. `[storage] keep` links `media` to a writable folder that survives deploys, because each deploy starts a new release from the commit. ## 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: ```python 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 SECRET_KEY`, which asks for the value. `ALLOWED_HOSTS` can point at ox's value as `${PUBLIC_HOST}`. Keys in `.env.example` that need your answer (empty, a secret, or a placeholder) 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: ```python 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: ```text 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 --wait`. If the health check fails, [Troubleshooting](https://console.t79.sorv.dev/docs/troubleshooting#health-check-failed) explains the message. ## Next steps - [Set SECRET_KEY and other variables](https://console.t79.sorv.dev/docs/operate/variables#set) from the dashboard or the CLI. - [Add your own domain with HTTPS](https://console.t79.sorv.dev/docs/operate/domains): one A record, and Caddy gets the certificate. - [Read and search the app's logs](https://console.t79.sorv.dev/docs/operate/logs), live or for a time range. - [Roll back a bad deploy](https://console.t79.sorv.dev/docs/operate/rollback) to a kept release without a rebuild. - [See the daily PostgreSQL backups](https://console.t79.sorv.dev/docs/operate/backups) and restore one. Last updated 2026-10-16. The page as HTML: https://console.t79.sorv.dev/docs/guides/django