docs/PLAN.md's Phase 0 section and its "Done when"/Verification entries still described the original Postgres+RLS, docker-compose, auto-redeploying design — none of which is what actually got built and deployed. Marks it done, states the two deliberate deviations plainly (SQLite not Postgres+RLS, manual redeploy not automatic), and separates what's actually verified (login persists a session, checked by scripts/smoke-test.sh after a real bug) from what nobody has tried yet (PWA home-screen install). docs/DECISIONS.md D19 records the auto-update investigation: the real fix for Unraid's own "not available" update-check badge (a third, independent place the D17 self-signed cert needed trusting — Unraid's PHP update checker doesn't share Docker's own certs.d), the structural reason "up to date" can't be fully trusted on this host even after that fix (CI builds on the same dockerd the app runs on, so the local :latest tag is always fresh regardless of whether the container was recreated from it), the failed first Watchtower attempt (stale image, wrong Docker API version) and why CI-triggers-a-redeploy was rejected again rather than reconsidered. "Deliberately deferred" gets three new entries: finishing Watchtower, migrating Gitea/CI to a dedicated VM (raised as the real fix for the root cause D19 kept running into), and persisting the accumulated host-local trust files across a reboot. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
174 lines
9.5 KiB
Markdown
174 lines
9.5 KiB
Markdown
# 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`):
|
|
|
|
```sh
|
|
docker build -t velodrome .
|
|
```
|
|
|
|
## Run
|
|
|
|
```sh
|
|
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):
|
|
|
|
```sh
|
|
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`:
|
|
|
|
```sh
|
|
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.
|
|
|
|
## Verify login actually works, not just that the API responds
|
|
|
|
`GET /api/v1/healthz` proves the process is up. It does **not** prove a real login works, because
|
|
the session cookie is set with `Secure` in production (`apps/api/velodrome/api/v1/auth.py`) —
|
|
browsers silently refuse to store a `Secure` cookie unless the request was actually served over
|
|
HTTPS. Test through a plain-HTTP address (an IP, a bare port, skipping the reverse proxy) and
|
|
`/auth/login` still returns 200 with a valid response body; the cookie is just quietly dropped, so
|
|
the very next request looks unauthenticated. From a browser this looks exactly like "I logged in
|
|
and it bounced me straight back to the login screen," with nothing that looks like an error. This
|
|
happened on the very first real deployment.
|
|
|
|
`scripts/smoke-test.sh` exists so this is caught by running a command, not by refreshing a browser
|
|
tab:
|
|
|
|
```sh
|
|
scripts/smoke-test.sh https://bike.bbergle.com you@example.com yourpassword
|
|
```
|
|
|
|
It logs in, confirms a session cookie was actually stored (not just sent), then makes an
|
|
authenticated follow-up request and confirms it succeeds and returns the right account. Run it
|
|
after every real deploy, against the actual public URL your users will use — testing against a
|
|
plain-HTTP IP will (correctly) tell you nothing about whether login works for anyone using the real
|
|
domain.
|
|
|
|
## 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.
|
|
|
|
Unraid's own "check for updates" is **not a reliable signal for this container specifically** — see
|
|
`docs/DECISIONS.md` D19. Because Gitea Actions builds on this same host's `dockerd`, every CI run
|
|
keeps the local `:latest` tag fresh regardless of whether the *running container* was ever
|
|
recreated from it, so the checker can say "up to date" while the running container is genuinely
|
|
stale. Don't wait for that badge; recreate deliberately after a merge you know should ship.
|
|
|
|
## 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 — both Phase 1+ concerns, nothing in the
|
|
schema uses them yet.
|
|
- An auto-updater for the running container (attempted with Watchtower, deferred — D19).
|
|
- Persisting the host-local trust material from D17/D19 (`/etc/hosts`, `certs.d`, the CA bundle
|
|
entry) across a reboot — currently lost on restart, deliberately left that way pending a
|
|
decision about editing `/boot/config/go` (D19).
|
|
- Migrating Gitea + its Actions runners off this Unraid host onto a dedicated VM — the root cause
|
|
behind several of the fixes above, raised as a real future decision, not started (D19).
|