Builds the actual single-container image the "1 container" decision (D15) needs — that PR
explicitly deferred this as follow-up work, and this is that follow-up.
Root Dockerfile: multi-stage build — SvelteKit static SPA, the API's venv, and a copied-out caddy binary, into one runtime image. Replaces apps/api/Dockerfile and apps/web/Dockerfile
from the old 4-container compose plan (PR #4, closed as superseded).
deploy/entrypoint.sh: runs alembic upgrade head, then supervises uvicorn (loopback-only) and
Caddy as two background processes under tini. If either dies, the other is killed and the
container exits non-zero so Docker/Unraid restarts it.
deploy/Caddyfile: serves the static build, proxies /api/* to uvicorn, SPA-fallbacks to index.html for client-side routes.
deploy/unraid-template.xml: turns VELODROME_PUBLIC_URL, VELODROME_SECRET_KEY, VELODROME_ENVIRONMENT, the data path, and the port into fillable Unraid Community Applications
web UI fields — the config-in-Unraid-UI-not-a-.env-file decision from earlier in this project.
.gitea/workflows/release.yml: builds and pushes the image to the Gitea registry on a v* tag
or manual dispatch. Does not touch the running container — rolling out a new image on the
Unraid host stays a manual/Unraid-side action.
docs/DECISIONS.md D16: records the specific choices and why — entrypoint-run migrations
instead of a separate deploy-pipeline step (and why that's an honest tradeoff, not a silent
contradiction of what apps/api/Dockerfile's old comment said), tini + a ~20-line bash script
instead of a real process supervisor, and why the Caddy binary is copied rather than switching
base images.
How this was verified
Built the image for real (docker build -t velodrome .) and ran it, not just built it:
GET /api/v1/healthz through Caddy's reverse proxy → {"status":"ok"}, HTTP 200.
GET / → the built SPA's index.html, HTTP 200.
GET /some/client/route → falls back to index.html, HTTP 200 (confirms the SPA-fallback
directive actually works, not just that it parses).
/data/velodrome.db exists and is non-empty (69,632 bytes) after container start — confirms alembic upgrade head actually ran from the entrypoint and produced real tables, not just that
the command is present in the script.
ls -la /data and whoami inside the container confirm the process runs as the non-root velodrome user, and owns the data directory it needs to write to.
Process tree inside the container: tini (pid 1) → entrypoint.sh → uvicorn and caddy as
siblings — confirms tini is actually PID 1 and the two processes are actually backgrounded
correctly.
Killed the in-container uvicorn process directly (kill <pid>) → Caddy was killed too and the
container exited with code 143 within ~1s. This is the property the whole entrypoint design
exists for (no half-alive container serving stale content from one dead process), and it was
confirmed by actually killing the process, not read off the script and trusted.
Not run (no node/pnpm on this machine): pnpm run lint/check against the changed apps/web/README.md. The Docker build's web-builder stage did successfully run pnpm run build
against the unchanged source, which is the part that matters functionally; the README edit is
prose only and should be inert for prettier/eslint, but CI's web job is the real check on that,
not an assumption made here.
Not run: .gitea/workflows/release.yml itself — it only fires on a version tag or manual dispatch,
neither of which this PR does. Pushing an image to the registry is a real external effect, so
that's left for a deliberate trigger rather than exercised here.
What's still not done
Backups (docs/PLAN.md calls for a systemd timer + restic against /data) — not built,
nothing in the schema needs it protected yet beyond what a manual copy of the volume would do.
The import_inbox USB-watch bind mount — Phase 1, ingestion doesn't exist yet.
Actually deploying this to the real Unraid host and importing the template — that's a real
action against a running system and is left for you to do when ready, not something this PR (or
CI) does unattended.
## Summary
Builds the actual single-container image the "1 container" decision (D15) needs — that PR
explicitly deferred this as follow-up work, and this is that follow-up.
- Root `Dockerfile`: multi-stage build — SvelteKit static SPA, the API's venv, and a copied-out
`caddy` binary, into one runtime image. Replaces `apps/api/Dockerfile` and `apps/web/Dockerfile`
from the old 4-container compose plan (PR #4, closed as superseded).
- `deploy/entrypoint.sh`: runs `alembic upgrade head`, then supervises uvicorn (loopback-only) and
Caddy as two background processes under `tini`. If either dies, the other is killed and the
container exits non-zero so Docker/Unraid restarts it.
- `deploy/Caddyfile`: serves the static build, proxies `/api/*` to uvicorn, SPA-fallbacks to
`index.html` for client-side routes.
- `deploy/unraid-template.xml`: turns `VELODROME_PUBLIC_URL`, `VELODROME_SECRET_KEY`,
`VELODROME_ENVIRONMENT`, the data path, and the port into fillable Unraid Community Applications
web UI fields — the config-in-Unraid-UI-not-a-.env-file decision from earlier in this project.
- `.gitea/workflows/release.yml`: builds and pushes the image to the Gitea registry on a `v*` tag
or manual dispatch. Does **not** touch the running container — rolling out a new image on the
Unraid host stays a manual/Unraid-side action.
- `docs/DECISIONS.md` D16: records the specific choices and why — entrypoint-run migrations
instead of a separate deploy-pipeline step (and why that's an honest tradeoff, not a silent
contradiction of what `apps/api/Dockerfile`'s old comment said), `tini` + a ~20-line bash script
instead of a real process supervisor, and why the Caddy binary is copied rather than switching
base images.
## How this was verified
Built the image for real (`docker build -t velodrome .`) and ran it, not just built it:
- `GET /api/v1/healthz` through Caddy's reverse proxy → `{"status":"ok"}`, HTTP 200.
- `GET /` → the built SPA's `index.html`, HTTP 200.
- `GET /some/client/route` → falls back to `index.html`, HTTP 200 (confirms the SPA-fallback
directive actually works, not just that it parses).
- `/data/velodrome.db` exists and is non-empty (69,632 bytes) after container start — confirms
`alembic upgrade head` actually ran from the entrypoint and produced real tables, not just that
the command is present in the script.
- `ls -la /data` and `whoami` inside the container confirm the process runs as the non-root
`velodrome` user, and owns the data directory it needs to write to.
- Process tree inside the container: `tini` (pid 1) → `entrypoint.sh` → `uvicorn` and `caddy` as
siblings — confirms tini is actually PID 1 and the two processes are actually backgrounded
correctly.
- Killed the in-container uvicorn process directly (`kill <pid>`) → Caddy was killed too and the
container exited with code 143 within ~1s. This is the property the whole entrypoint design
exists for (no half-alive container serving stale content from one dead process), and it was
confirmed by actually killing the process, not read off the script and trusted.
Not run (no node/pnpm on this machine): `pnpm run lint`/`check` against the changed
`apps/web/README.md`. The Docker build's `web-builder` stage did successfully run `pnpm run build`
against the unchanged source, which is the part that matters functionally; the README edit is
prose only and should be inert for prettier/eslint, but CI's `web` job is the real check on that,
not an assumption made here.
Not run: `.gitea/workflows/release.yml` itself — it only fires on a version tag or manual dispatch,
neither of which this PR does. Pushing an image to the registry is a real external effect, so
that's left for a deliberate trigger rather than exercised here.
## What's still not done
- Backups (`docs/PLAN.md` calls for a systemd timer + `restic` against `/data`) — not built,
nothing in the schema needs it protected yet beyond what a manual copy of the volume would do.
- The `import_inbox` USB-watch bind mount — Phase 1, ingestion doesn't exist yet.
- Actually deploying this to the real Unraid host and importing the template — that's a real
action against a running system and is left for you to do when ready, not something this PR (or
CI) does unattended.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Builds the container the "1 container" decision (D15) actually needs, which
D15 itself deferred as follow-up work: Caddy + the FastAPI app + the static
SvelteKit build in one image, SQLite on a mounted volume. See docs/DECISIONS.md
D16 for the specific choices and why (entrypoint-run migrations instead of a
separate deploy-pipeline step, tini + a small supervisor script instead of
s6-overlay/supervisord, copying the Caddy binary out of its official image).
Removes apps/api/Dockerfile and apps/web/Dockerfile from the old 4-container
compose plan (PR #4, closed as superseded) — the root Dockerfile replaces both
with one multi-stage build.
deploy/unraid-template.xml turns VELODROME_PUBLIC_URL, VELODROME_SECRET_KEY,
etc. into fillable Unraid Community Applications web UI fields, per the
earlier decision to keep config there instead of a .env file.
.gitea/workflows/release.yml builds and pushes the image to the Gitea registry
on a version tag or manual dispatch; it does not touch the running container.
Verified by actually running the built image, not just building it: the
health endpoint responds through Caddy's proxy, the SPA serves with working
client-route fallback, alembic ran and produced a real (non-empty) SQLite file
under /data, the process runs as the non-root velodrome user, and killing the
uvicorn process brings the whole container down (exit 143) rather than
leaving Caddy serving alone — confirming the entrypoint's coupled-lifetime
behavior actually holds, not just that it reads correctly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
BBergle
merged commit 70e0182177 into main2026-09-21 15:58:43 -04:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Builds the actual single-container image the "1 container" decision (D15) needs — that PR
explicitly deferred this as follow-up work, and this is that follow-up.
Dockerfile: multi-stage build — SvelteKit static SPA, the API's venv, and a copied-outcaddybinary, into one runtime image. Replacesapps/api/Dockerfileandapps/web/Dockerfilefrom the old 4-container compose plan (PR #4, closed as superseded).
deploy/entrypoint.sh: runsalembic upgrade head, then supervises uvicorn (loopback-only) andCaddy as two background processes under
tini. If either dies, the other is killed and thecontainer exits non-zero so Docker/Unraid restarts it.
deploy/Caddyfile: serves the static build, proxies/api/*to uvicorn, SPA-fallbacks toindex.htmlfor client-side routes.deploy/unraid-template.xml: turnsVELODROME_PUBLIC_URL,VELODROME_SECRET_KEY,VELODROME_ENVIRONMENT, the data path, and the port into fillable Unraid Community Applicationsweb UI fields — the config-in-Unraid-UI-not-a-.env-file decision from earlier in this project.
.gitea/workflows/release.yml: builds and pushes the image to the Gitea registry on av*tagor manual dispatch. Does not touch the running container — rolling out a new image on the
Unraid host stays a manual/Unraid-side action.
docs/DECISIONS.mdD16: records the specific choices and why — entrypoint-run migrationsinstead of a separate deploy-pipeline step (and why that's an honest tradeoff, not a silent
contradiction of what
apps/api/Dockerfile's old comment said),tini+ a ~20-line bash scriptinstead of a real process supervisor, and why the Caddy binary is copied rather than switching
base images.
How this was verified
Built the image for real (
docker build -t velodrome .) and ran it, not just built it:GET /api/v1/healthzthrough Caddy's reverse proxy →{"status":"ok"}, HTTP 200.GET /→ the built SPA'sindex.html, HTTP 200.GET /some/client/route→ falls back toindex.html, HTTP 200 (confirms the SPA-fallbackdirective actually works, not just that it parses).
/data/velodrome.dbexists and is non-empty (69,632 bytes) after container start — confirmsalembic upgrade headactually ran from the entrypoint and produced real tables, not just thatthe command is present in the script.
ls -la /dataandwhoamiinside the container confirm the process runs as the non-rootvelodromeuser, and owns the data directory it needs to write to.tini(pid 1) →entrypoint.sh→uvicornandcaddyassiblings — confirms tini is actually PID 1 and the two processes are actually backgrounded
correctly.
kill <pid>) → Caddy was killed too and thecontainer exited with code 143 within ~1s. This is the property the whole entrypoint design
exists for (no half-alive container serving stale content from one dead process), and it was
confirmed by actually killing the process, not read off the script and trusted.
Not run (no node/pnpm on this machine):
pnpm run lint/checkagainst the changedapps/web/README.md. The Docker build'sweb-builderstage did successfully runpnpm run buildagainst the unchanged source, which is the part that matters functionally; the README edit is
prose only and should be inert for prettier/eslint, but CI's
webjob is the real check on that,not an assumption made here.
Not run:
.gitea/workflows/release.ymlitself — it only fires on a version tag or manual dispatch,neither of which this PR does. Pushing an image to the registry is a real external effect, so
that's left for a deliberate trigger rather than exercised here.
What's still not done
docs/PLAN.mdcalls for a systemd timer +resticagainst/data) — not built,nothing in the schema needs it protected yet beyond what a manual copy of the volume would do.
import_inboxUSB-watch bind mount — Phase 1, ingestion doesn't exist yet.action against a running system and is left for you to do when ready, not something this PR (or
CI) does unattended.
🤖 Generated with Claude Code