# 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. ## 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: ```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://deploywithox.com/docs/config#static)), 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: ```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 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: ```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, 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://deploywithox.com/docs/troubleshooting#health-check-failed) explains the message. ## Next steps - [Set SECRET_KEY and other variables](https://deploywithox.com/docs/operate/variables#set) from the dashboard or the CLI. - [Add your own domain with HTTPS](https://deploywithox.com/docs/operate/domains): one A record, and Caddy gets the certificate. - [Read and search the app's logs](https://deploywithox.com/docs/operate/logs), live or for a time range. - [Roll back a bad deploy](https://deploywithox.com/docs/operate/rollback) to a kept release without a rebuild. - [See the daily PostgreSQL backups](https://deploywithox.com/docs/operate/backups) and restore one. Last updated 2026-10-08. The page as HTML: https://deploywithox.com/docs/guides/django