# Deployment

## Overview

There are three ways to deploy: the one-command VPS script, Docker Compose, or a manual setup. All of them must meet the production requirements below. The full contract lives in [SECURITY.md](https://github.com/level09/stk/blob/master/SECURITY.md).

## Production requirements

Set these in `.env` before the app serves real traffic. `stk init` writes development values (`QUART_DEBUG=1`, `STK_ENV=development`, `SESSION_COOKIE_SECURE=False`), so change them.

```bash
STK_ENV=production
QUART_DEBUG=0
SESSION_COOKIE_SECURE=True
SECRET_KEY=...                  # unique, generated
SECURITY_PASSWORD_SALT=...      # unique, generated
STK_PUBLIC_URL=https://app.example.com
SECURITY_WAN_RP_ID=app.example.com
SECURITY_WAN_EXPECTED_ORIGIN=https://app.example.com
```

`STK_PUBLIC_URL` restricts accepted request hosts and fixes OAuth callback URLs. The WebAuthn values must match the public host and origin or passkeys fail. Keep `SECURITY_REGISTERABLE=False` unless the app needs public sign-up.

`SECRET_KEY` also derives the default key for encrypted TOTP seeds, so keep it stable, or move to `SECURITY_TOTP_ENCRYPTION_KEYS` as described in [Authentication](/authentication). Back up the MFA keys separately from the database.

Trust proxy headers only from your own proxy. Rate limits key on the client IP, so a misconfigured proxy either collapses all users onto one IP or lets clients spoof theirs.

## Upgrading an existing deployment

Take a protected backup, then before starting the new version:

```bash
uv sync --frozen --extra full    # drop --extra full if you do not use PostgreSQL or Redis
uv run stk db upgrade
uv run stk protect-mfa
```

`protect-mfa` encrypts legacy TOTP seeds and rotates existing ones to the current key in one transaction. stk 17 starts a new migration history (one `stk_0001` revision), so it does not upgrade a database created by an earlier version.

## Upgrading stk in an app

An app depends on stk like any package, so an upgrade touches no app file except the version. Read the release note first: its "Breaking" and "Upgrade considerations" sections list anything a version bump cannot do, such as a server config change. Then, on a branch:

```bash
# for a new major version, first raise the stkit range in pyproject.toml dependencies
uv lock --upgrade-package stkit
uv sync
uv run stk db upgrade     # stk's new migrations, then the app's, in one command
uv run stk verify
uv run stk smoke
```

stk's migrations and the app's are separate lineages in one version table, so a release that adds core migrations needs no merge revision. Templates the app overrides keep winning; templates it does not override pick up the release's changes.

## VPS deploy script

On a fresh Ubuntu 22.04 or 24.04 server:

```bash
wget -qO /tmp/deploy.sh https://raw.githubusercontent.com/level09/stk/master/deploy.sh && sudo DOMAIN=example.com REPO=you/yourapp bash /tmp/deploy.sh
```

`deploy.sh` deploys an app made with `stk init` from its GitHub repository. It installs Caddy with automatic TLS, uv-managed Python 3.13, a dedicated app user, UFW, fail2ban, and key-only SSH. It generates a production `.env` with fresh secrets and the public URL and WebAuthn values, applies migrations, creates an admin user, and runs the app named by `[tool.stk] app` under uvicorn, as a systemd service named after the domain. SQLite is the default; `DB=postgres` adds PostgreSQL, Redis, and the `full` extra. Admin credentials are saved to `/home/<user>/.credentials`.

| Variable | Default | Description |
|----------|---------|-------------|
| `DOMAIN` | (required) | Domain name, or server IP with `SKIP_SSL=true` |
| `REPO` | (required) | Your app's GitHub repository, `owner/name` |
| `BRANCH` | `master` | Branch to deploy |
| `DB` | `sqlite` | `sqlite` or `postgres` |
| `ADMIN_EMAIL` | `admin@$DOMAIN` | Admin login email |
| `ADMIN_PASSWORD` | (generated) | Admin password |
| `SKIP_SSL` | `false` | `true` serves plain HTTP, for IP-only testing |
| `PYTHON_PORT` | `5000` | Internal uvicorn port |

`deploy.sh` is install-only. It deletes and re-clones the app directory, and with SQLite the database lives inside it at `instance/stk.db`, so running it again on a live server destroys production data. It also clones over plain HTTPS, so a private repository fails at the clone step: make it public, or clone it yourself with a read-only deploy key first.

Commit the app's `uv.lock`: the install syncs from it. Update a deployed app as the app user, in the app directory:

```bash
git pull && uv sync --frozen --no-dev && uv run stk db upgrade && sudo systemctl restart <domain>.service
```

Add `--extra full` to the sync on a PostgreSQL install, and run `protect-mfa` as above. The app user's sudoers entry allows managing only its own service.

Check a deploy with `curl -sI https://<domain>/` (expect 200 or a redirect to login) and `systemctl is-active <domain>.service`, then sign in with the saved credentials, create a record, restart the service, and confirm the record is still there.

## Docker Compose

In the app directory, using the `Dockerfile` and `docker-compose.yml` that `stk init` wrote:

```bash
docker compose up --build
```

The stack runs Redis, PostgreSQL 15, the app, and Nginx. The app container runs `stk db upgrade` before starting uvicorn, and Compose sets `STK_ENV=production`, `QUART_DEBUG=0`, `SESSION_COOKIE_SECURE=True`, the database and Redis URLs, and the public URL and WebAuthn values from `STK_HOST`. Compose refuses to start until `.env` provides:

```bash
DB_ADMIN_PASSWORD=...     # PostgreSQL superuser
DB_PASSWORD=...           # app database role
REDIS_PASSWORD=...
STK_HOST=app.example.com
TLS_CERTIFICATE=/absolute/path/fullchain.pem
TLS_PRIVATE_KEY=/absolute/path/key.pem
```

Passwords must be unique and URL-safe because they are embedded in connection URLs. `STK_HOST` is the bare DNS name; Compose derives `https://` URLs from it. The certificate paths must be absolute, valid for that host, and readable by the Nginx container user (`DOCKER_UID`, default 1000). Renew certificates and reload Nginx before they expire.

Only Nginx is published: port 80 redirects to HTTPS on 443. A new PostgreSQL volume gets a separate non-superuser `stk` role for the app, created by the app's `postgres/init-db.sh`. That role cannot create extensions, so add any your migrations need (PostGIS, pgvector) to that script, which runs as the superuser. Initialization scripts do not run on an existing volume, so review that role's privileges yourself.

## Manual setup

You need Python 3.11+, uv, and a TLS-terminating reverse proxy. PostgreSQL and Redis are optional.

```bash
git clone https://github.com/you/yourapp.git
cd yourapp
uv sync --frozen --no-dev     # add --extra full for PostgreSQL or Redis
# write .env from .env-sample, with the production requirements above
uv run stk db upgrade
uv run stk install
```

SQLite works with no configuration and lives in `instance/stk.db` beside the app package. For PostgreSQL, set `SQLALCHEMY_DATABASE_URI=postgresql+asyncpg://user:password@localhost/dbname`; the `postgresql://` scheme will not work. For Redis sessions, set `REDIS_URL`.

Run uvicorn under systemd, bound to localhost and trusting forwarded headers only from the local proxy. Its target is the `[tool.stk] app` value from the app's `pyproject.toml`:

```ini
[Unit]
Description=stk
After=network.target

[Service]
User=your_user
WorkingDirectory=/path/to/yourapp
EnvironmentFile=/path/to/yourapp/.env
ExecStart=/path/to/yourapp/.venv/bin/uvicorn yourapp.app:app --host 127.0.0.1 --port 5000 --ws-max-size 65536 --ws-max-queue 16 --proxy-headers --forwarded-allow-ips=127.0.0.1
Restart=always

[Install]
WantedBy=multi-user.target
```

The proxy must pass `Host` and `X-Forwarded-Proto` and upgrade WebSocket connections. Let it pass `/static/` to the app too: the app serves the app's files before stk's and sets their cache headers by version, which a proxy reading one folder from disk cannot. With Nginx:

```nginx
location / {
    proxy_pass http://127.0.0.1:5000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
}
```

`$connection_upgrade` needs the `map` block from the app's `nginx/stk.conf`. Use Certbot or your own certificates for TLS.

## Troubleshooting

Requests rejected or OAuth redirecting to the wrong host: `STK_PUBLIC_URL` must match the URL users visit.

Database connection errors: check credentials in `.env` and that the URI uses `postgresql+asyncpg://`.

App not starting: `sudo journalctl -u <service>`. Missing `SECRET_KEY` or `SECURITY_PASSWORD_SALT` stops startup.

WebSockets not connecting: the proxy needs HTTP/1.1 with the `Upgrade` and `Connection` headers, and the browser origin must match the app's.