Skip to content

Deployment

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.

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.

Terminal window
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. 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.

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

Terminal window
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.

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:

Terminal window
# 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.

On a fresh Ubuntu 22.04 or 24.04 server:

Terminal window
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:

Terminal window
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.

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

Terminal window
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:

Terminal window
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.

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

Terminal window
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:

[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:

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.

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.