Found on the actual first deployment: /api/v1/healthz proves the process is up, but says nothing about whether login actually works, because the session cookie is set with Secure in production. Test through a plain-HTTP address (an IP, a bare port, skipping the reverse proxy) and /auth/login still returns 200 with a valid body — the cookie is just silently dropped by the client, 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 no error anywhere to point at. scripts/smoke-test.sh does the real round trip a browser does: login, confirm a session cookie was actually stored (not just sent), then an authenticated follow-up request confirming it succeeds and returns the right account. Verified it actually catches what it's meant to catch before committing: ran it against a throwaway account over plain HTTP against a production-mode container and got the expected FAIL with a diagnostic pointing at the Secure-cookie mismatch, then confirmed PASS once the container's VELODROME_ENVIRONMENT was (inadvertently, in this case) development instead. Documented in deploy/README.md as the real post-deploy check, replacing "hit /healthz and eyeball it" for anything involving auth. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
162 lines
8.6 KiB
Markdown
162 lines
8.6 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.
|
|
|
|
## 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.
|