Deployment
Overview
Section titled “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.
Production requirements
Section titled “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.
STK_ENV=productionQUART_DEBUG=0SESSION_COOKIE_SECURE=TrueSECRET_KEY=... # unique, generatedSECURITY_PASSWORD_SALT=... # unique, generatedSTK_PUBLIC_URL=https://app.example.comSECURITY_WAN_RP_ID=app.example.comSECURITY_WAN_EXPECTED_ORIGIN=https://app.example.comSTK_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.
Upgrading an existing deployment
Section titled “Upgrading an existing deployment”Take a protected backup, then before starting the new version:
uv sync --frozen --extra full # drop --extra full if you do not use PostgreSQL or Redisuv run stk db upgradeuv run stk protect-mfaprotect-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
Section titled “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:
# for a new major version, first raise the stkit range in pyproject.toml dependenciesuv lock --upgrade-package stkituv syncuv run stk db upgrade # stk's new migrations, then the app's, in one commanduv run stk verifyuv run stk smokestk’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
Section titled “VPS deploy script”On a fresh Ubuntu 22.04 or 24.04 server:
wget -qO /tmp/deploy.sh https://raw.githubusercontent.com/level09/stk/master/deploy.sh && sudo DOMAIN=example.com REPO=you/yourapp bash /tmp/deploy.shdeploy.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:
git pull && uv sync --frozen --no-dev && uv run stk db upgrade && sudo systemctl restart <domain>.serviceAdd --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
Section titled “Docker Compose”In the app directory, using the Dockerfile and docker-compose.yml that stk init wrote:
docker compose up --buildThe 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:
DB_ADMIN_PASSWORD=... # PostgreSQL superuserDB_PASSWORD=... # app database roleREDIS_PASSWORD=...STK_HOST=app.example.comTLS_CERTIFICATE=/absolute/path/fullchain.pemTLS_PRIVATE_KEY=/absolute/path/key.pemPasswords 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
Section titled “Manual setup”You need Python 3.11+, uv, and a TLS-terminating reverse proxy. PostgreSQL and Redis are optional.
git clone https://github.com/you/yourapp.gitcd yourappuv sync --frozen --no-dev # add --extra full for PostgreSQL or Redis# write .env from .env-sample, with the production requirements aboveuv run stk db upgradeuv run stk installSQLite 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=stkAfter=network.target
[Service]User=your_userWorkingDirectory=/path/to/yourappEnvironmentFile=/path/to/yourapp/.envExecStart=/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.1Restart=always
[Install]WantedBy=multi-user.targetThe 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.
Troubleshooting
Section titled “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.

