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:
-
Install / refresh the deploy key - the per-site keypair is written to
~pyvolt/.ssh/deploy_<domain>and registered in~pyvolt/.ssh/configas a host alias so multiple apps on the same server can clone different private repos. -
git clone(first deploy) orgit fetch && git reset --hard origin/<branch>. -
Create venv -
python -m venv venvif it doesn't already exist. Python version is resolved by mise per app. -
Install dependencies -
uv sync/poetry install/pip install -r requirements.txtdepending on the detected package manager. -
Build step - runs
app.build_commandif set. Most apps leave this empty. See Build commands below. -
Release command - your explicit release steps, one line per step. Django apps get
python manage.py collectstatic --noinputandpython manage.py migrate --noinputprefilled at create time; edit them in Settings. Nothing framework-specific runs unless it's listed here - no hidden magic. -
Write systemd service -
/etc/systemd/system/pyvolt-<domain>.service. -
Write nginx vhost -
/etc/nginx/sites-available/<domain>plus a symlink insites-enabled/. -
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
.nvmrcorengines.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 aspyvolt- 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.