Deploying a Django app

This page walks through Pyvolt's deploy pipeline - what runs, in what order, and how to customise it for your project.

Pipeline

When you trigger a deploy (manually or via webhook), Pyvolt's engine runs the following SSH steps in order:

  1. Install / refresh the deploy key - the per-site keypair is written to ~pyvolt/.ssh/deploy_<domain> and registered in ~pyvolt/.ssh/config as a host alias so multiple apps on the same server can clone different private repos.

  2. git clone (first deploy) or git fetch && git reset --hard origin/<branch>.

  3. Create venv - python -m venv venv if it doesn't already exist. Python version is resolved by mise per app.

  4. Install dependencies - uv sync / poetry install / pip install -r requirements.txt depending on the detected package manager.

  5. Build step - runs app.build_command if set. Most apps leave this empty. See Build commands below.

  6. Release command - your explicit release steps, one line per step. Django apps get python manage.py collectstatic --noinput and python manage.py migrate --noinput prefilled at create time; edit them in Settings. Nothing framework-specific runs unless it's listed here - no hidden magic.

  7. Write systemd service - /etc/systemd/system/pyvolt-<domain>.service.

  8. Write nginx vhost - /etc/nginx/sites-available/<domain> plus a symlink in sites-enabled/.

  9. Restart services - the app service plus every background process, - systemctl restart pyvolt-<domain>.

If any step fails the deploy stops; the failure plus its log line is surfaced on the Deployments tab and an entry is logged in Activity.

Autodiscovery

When you connect a GitHub repo at site-create time (or hit Re-detect from repo on the Settings tab), Pyvolt fetches a short list of files from the repo (manage.py, pyproject.toml, lockfiles, package.json, .nvmrc, etc.) and fills in:

Field Source
Package manager Lockfile presence (uv.lock → uv, poetry.lock → poetry, …)
Python version .python-version, pyproject.toml [requires-python], etc.
Settings module os.environ.setdefault("DJANGO_SETTINGS_MODULE", …) in manage.py
Application module Derived from settings module + protocol (myapp.wsgi:application)
App protocol ASGI if channels / daphne deps OR ASGI_APPLICATION = … in settings
Build command package.json build script present → npm install && npm run build (or yarn/pnpm equivalent)
Node version .nvmrc or package.json engines.node

You can edit any of these on the Settings tab - autodiscovery picks defaults, you have the final word.

Build commands

The Build card on the Settings page exposes:

  • Build command - multi-line bash, runs after dep install and before collectstatic + migrate. Empty = no build step (default for pure-Django apps).

  • Node version - Node tool version resolved by mise. Blank = read from .nvmrc or engines.node.

Common shapes:

Project Build command
Pure Django (empty)
Django + Tailwind v4 CLI npm install && npm run build
Django + Vite npm install && npm run build
Django + Webpack npm install && npm run production
Django + esbuild script npm install && node esbuild.config.mjs
Hugo / Astro static app as sibling cd ../frontend && npm install && npm run build

The command runs in your project directory (the project_path if you set one, otherwise the repo root). mise install runs first so any tools declared in package.json engines.node / .nvmrc / .tool-versions are installed before your script executes.

Project layouts

Layout project_path
manage.py at repo root (leave blank)
manage.py in a subdirectory (monorepo) backend
Standard with src/ layout src

For the monorepo case, the build command's working directory matches project_path. If your front-end build lives in a sibling directory (e.g. frontend/ next to backend/), the build command can cd ../frontend explicitly.

What runs as which user

  • Privileged steps (systemd write, nginx reload) run as root.
  • Everything else (git clone, uv sync, npm install, manage.py collectstatic|migrate) runs as pyvolt - your code never sees a root shell.

Environment variables

.env files at /home/pyvolt/sites/<domain>/.env are read by Gunicorn via systemd's EnvironmentFile=. Edit through App → Env (the live file is read over SSH, edited inline, and Gunicorn restarts on save).

Set anything your settings module expects: DJANGO_SECRET_KEY, DJANGO_DEBUG=False, ALLOWED_HOSTS, DATABASE_URL, etc.

Release commands run with a minimal environment by default. For manage.py migrate to see DATABASE_URL, enable Expose to release commands on the Env tab, or set release_env = true under [app] in pyvolt.toml.

Platform-managed env vars

Pyvolt injects a small set of variables into every Gunicorn unit automatically, so apps can rely on them without configuring anything:

Variable Value Purpose
PYVOLT_HOST the app domain Use in ALLOWED_HOSTS so the host header check passes
PYVOLT_SHARED_DIR absolute path to the shared dir Persistent directory that survives deploys, put SQLite, uploads, and any writable state here

Wire it into settings.py:

import os
ALLOWED_HOSTS = [os.environ.get("PYVOLT_HOST", "localhost")]

Without this, Django 400s every request with DisallowedHost: Invalid HTTP_HOST header because ALLOWED_HOSTS = [] by default in production mode. Same pattern as Render's RENDER_EXTERNAL_HOSTNAME and Heroku's auto-injected hostname vars.

HTTPS, proxy headers and CSRF

Pyvolt's nginx terminates TLS and proxies plain HTTP to Gunicorn - the standard reverse-proxy setup - and it already sends the X-Forwarded-Proto header. Two Django settings make your app aware of that. Without them, GET requests work and the login page renders, but the first POST returns a 403 ("CSRF verification failed. Origin checking failed"):

import os

# Trust nginx's X-Forwarded-Proto header, so request.is_secure() is True
# and Django treats the connection as HTTPS.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

# Your domain must be a trusted CSRF origin, or every POST (login, any
# form) is rejected.
CSRF_TRUSTED_ORIGINS = [f"https://{os.environ.get('PYVOLT_HOST', 'localhost')}"]

PYVOLT_HOST is injected for you (see above), so CSRF_TRUSTED_ORIGINS stays correct if your domain changes. Every TLS-terminating platform (Render, Fly) needs these same two lines; it is not Pyvolt-specific.

django-allauth 65+

allauth resolves a client IP for its rate limiting and returns a 403 (Permission denied) from signup and login when it cannot. Behind Pyvolt's nginx your app listens on a unix socket, so REMOTE_ADDR is empty - tell allauth to trust the header nginx sets:

ALLAUTH_TRUSTED_CLIENT_IP_HEADER = "X-Real-IP"

Where to put writable state (SQLite, uploads)

Deploys are release-based: each deploy checks your code out into a fresh releases/<timestamp>/ directory and flips a current symlink to it. Anything your app writes next to its code is discarded on the next deploy - a repo-relative SQLite file loses every row, and locally-saved uploads vanish.

Writable state must live in the per-app shared directory, which persists across deploys (it already holds your .env, media/ and staticfiles/). Pyvolt injects its absolute path as PYVOLT_SHARED_DIR, so you never hardcode it. A SQLite database:

import os
from pathlib import Path

SHARED = Path(os.environ.get("PYVOLT_SHARED_DIR", BASE_DIR))

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": SHARED / "db.sqlite3",
    }
}

and user uploads:

MEDIA_ROOT = SHARED / "media"

The os.environ.get(..., BASE_DIR) fallback keeps the same settings working locally, where PYVOLT_SHARED_DIR isn't set.

For anything past a hobby project, use the managed Postgres instead: it lives outside the release tree by definition and is backed up. SQLite on the shared disk is fine for small, low-write apps.

View this page as markdown - handy as context for your AI tools. Full index at /llms.txt.