# 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](https://mise.jdx.dev) 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](#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](pyvolt-toml.md).

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

```python
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"):

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

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

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

```python
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.
