Files
BBergleandClaude Opus 5 d0c0d98307
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 19s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 54s
docs: record D18 (admin bootstrap) and the deploy bootstrap step
The deploy README described how to start the container but not how to get
into it, which left the first-run experience at a login page nobody can get
past. Adds the actual command, both the interactive and the piped form, and
says why there is no --password flag.

D18 records the three decisions worth arguing with later rather than
rediscovering: why this is a CLI instead of a bootstrap HTTP endpoint or an
env var (both rejected, with reasons), why it refuses an existing email, why
it is not restricted to the first user, and why the admin role is recorded
but not yet enforced.

Numbered D18 because D17 was taken by the registry-TLS decision that merged
while this branch was in flight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 22:31:15 -04:00
..

deploy/

Everything needed to run Velodrome as one container. See docs/DECISIONS.md D15 (why SQLite and one container) and D16 (why migrations run from the entrypoint, and the Caddy/tini setup) before changing anything here.

../Dockerfile          Multi-stage build: web SPA + API venv + Caddy, into one runtime image
Caddyfile               Serves the static SPA, proxies /api/* to uvicorn on loopback
entrypoint.sh           Runs migrations, then supervises uvicorn + Caddy as PID 1's children
unraid-template.xml     Unraid Community Applications template — turns the env vars below into
                        fillable web UI fields instead of a .env file

There is no docker-compose here, deliberately — the app is one container, not a set of services that need orchestrating together. (Later phases may add genuinely separate containers — a tileserver, a local LLM — see docs/PLAN.md's "Service topology"; those would get their own compose file or Unraid templates when a phase actually needs one, not speculatively now.)

Build

From the repo root (the build context — the Dockerfile needs both apps/api and apps/web):

docker build -t velodrome .

Run

docker run -d \
  --name velodrome \
  -p 8080:8080 \
  -v velodrome-data:/data \
  -e VELODROME_PUBLIC_URL=https://bikes.example.com \
  -e VELODROME_SECRET_KEY=$(openssl rand -hex 32) \
  -e VELODROME_ENVIRONMENT=production \
  velodrome

Put a TLS-terminating reverse proxy (whatever's already fronting other services on the host) in front of port 8080 — this container only ever serves plain HTTP itself.

GET http://<host>:8080/api/v1/healthz should return {"status": "ok"} once it's up.

Create the first admin user

A fresh deployment has no users and you cannot sign up for one. Registration requires an invite code, invites are issued by an existing admin, and a new database has neither — so the first account is created from inside the container (docs/DECISIONS.md D18 for why it's a CLI and not a first-run web page):

docker exec -it velodrome velodrome create-admin --email you@example.com

That prompts for the password twice and prints the new account's id, email and role. Then log in at VELODROME_PUBLIC_URL. Note the -t — without a TTY there's nothing to prompt on; the command says so rather than hanging. Add --name "Your Name" to set a display name (it defaults to the part of the email before the @); it's editable in the UI later either way.

For a non-interactive run (a provisioning script), pipe the password in instead — note -i rather than -it:

printf '%s' "$ADMIN_PASSWORD" | docker exec -i velodrome \
  velodrome create-admin --email you@example.com --password-stdin

There is deliberately no --password flag: an argument would land in your shell history, in ps output, and in the Docker daemon's record of the exec'd command.

Re-run it with a different --email to add another admin. Re-running it with an email that already exists refuses and changes nothing — it is not a password-reset tool, and there isn't one yet (D18). Minimum password length is 8 characters, the same rule the register endpoint applies.

Nothing enforces the admin role yet — no admin-only endpoint exists — so today this differs from an invited account only in the role recorded on it. Invite management in a later phase is what starts reading it.

Environment variables

All read by apps/api/velodrome/config.py (prefix VELODROME_) — the app and Alembic both read the same values, there's no separate migration-time config anymore (docs/DECISIONS.md D15).

Variable Required Default (baked into the image) Notes
VELODROME_DATABASE_URL No — don't override sqlite+aiosqlite:////data/velodrome.db Fixed to the /data volume mount. Change the volume mapping, not this.
VELODROME_PUBLIC_URL Yes none The externally-visible URL. Checked against Origin on cookie-authenticated mutations — get this wrong and every logged-in write silently 403s.
VELODROME_SECRET_KEY Yes insecure dev placeholder openssl rand -hex 32. Not yet used for anything reachable (arrives with Bryton credential encryption in a later phase) — set a real value now anyway.
VELODROME_ENVIRONMENT Yes development development | test | production. Gates the session cookie's Secure flag — always production behind real HTTPS.
VELODROME_SESSION_TTL_DAYS No 90 Login session lifetime.
VELODROME_SESSION_COOKIE_NAME No vd_session Only matters if it collides with another app on the same domain.

Volumes

Path Contents
/data The SQLite database file. Will also hold the content-addressed blob store once Phase 1 builds ingestion. This is the only thing that needs backing up.

The container runs as a fixed non-root user (uid/gid 999), not root and not Unraid's usual nobody:users (99:100). If /data's host directory doesn't already exist, Docker/Unraid creates it owned by nobody:users with no write access for other users — the container starts, but uvicorn fails immediately with sqlite3.OperationalError: unable to open database file, since it can't create the SQLite file inside a directory it can't write to. Fix once, before first start: chown -R 999:999 <host path> (e.g. /mnt/user/appdata/velodrome on Unraid).

Unraid

Import unraid-template.xml from the Docker tab's "Add Container" template picker — it exposes the Port, Data path, and the env vars above as fillable web UI fields, matching the earlier decision to keep configuration in Unraid's own UI rather than a .env file on disk. Every field stays editable by hand afterward regardless of what the template pre-fills.

Publishing the image

.gitea/workflows/release.yml builds this Dockerfile and pushes it to the Gitea container registry at registry.bbergle.com:9537/bbergle/bike-app on every push to main (tagged main-<short-sha>, and latest), on a v* tag push (tagged with the tag name, and latest), or on manual workflow_dispatch (tagged manual-<timestamp>-<short-sha> only — a manual dispatch never moves latest, so testing a feature branch can't clobber what's actually deployable). Not 192.168.0.3:3000 (Gitea's own plain-HTTP address) directly — Docker refuses any non-localhost registry over plain HTTP by default, so registry.bbergle.com:9537 is an NPMplus proxy host in front of Gitea's registry that terminates TLS with a self-signed cert. See docs/DECISIONS.md D17 for the full setup (cert, NPMplus proxy host, certs.d trust, and the buildx driver change this required) — none of it is committed here, since it's host-local trust material and NPMplus config, not something this repo can or should own.

It does not SSH into the host and recreate the running container — rolling out a new image on Unraid (pulling it and clicking "Apply" on the container, or via Unraid's own update-checking) is left as a manual/Unraid-side step, not something CI does unattended.

What's not here yet

Backups (docs/PLAN.md calls for a systemd timer running restic against /data, independent of CI) and the import_inbox USB-watch bind mount are both Phase 1+ concerns — nothing in the schema uses them yet.