chore(deploy): single-container Dockerfile, Caddy, and Unraid template #6

Merged
BBergle merged 1 commits from chore/deploy-single-container into main 2026-09-21 15:58:43 -04:00
Owner

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.shuvicorn 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

## 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)
BBergle added 1 commit 2026-09-21 15:56:12 -04:00
chore(deploy): single-container Dockerfile, Caddy, and Unraid template
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 15s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 53s
6c48000d7b
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 main 2026-09-21 15:58:43 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: BBergle/bike-app#6