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

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
This commit is contained in:
2026-09-21 15:55:50 -04:00
co-authored by Claude Sonnet 5
parent 9fa50cb2ea
commit 6c48000d7b
12 changed files with 378 additions and 89 deletions
+52
View File
@@ -181,6 +181,58 @@ containment), #6 (single ingestion path) — none of those were ever Postgres-sp
(D6, opaque bearer tokens) is unaffected. FastAPI/SQLAlchemy/Alembic stay exactly as chosen in D4;
only the database engine underneath them changed.
### D16 — Single-container packaging: entrypoint migrations, tini + a two-line supervisor, Caddy binary copy, Unraid template
**Chosen:** one Docker image (root `Dockerfile`), built by copying the SvelteKit static build and
the API's venv into a runtime stage alongside a copied-out `caddy` binary. `deploy/entrypoint.sh`
runs `alembic upgrade head`, then starts uvicorn (loopback-only) and Caddy as two background
processes under `tini` as PID 1, and kills+exits if either one dies. Config surfaces as env vars
read by the existing `VELODROME_`-prefixed Pydantic settings; `deploy/unraid-template.xml` exposes
the required ones as Unraid Community Applications web UI fields instead of a `.env` file.
**Why not a real process manager (s6-overlay, supervisord):** two long-running processes with no
dependency graph between them (Caddy doesn't need to wait on uvicorn — it just proxies) doesn't
need a supervisor with restart policies, readiness ordering, or log multiplexing. A ~20-line bash
script under `tini` (for correct signal forwarding and zombie reaping, which a bare shell script as
PID 1 doesn't do) gets the one property that matters — if either process dies, the whole container
exits non-zero so Docker/Unraid restarts it — without a new dependency or a config format to learn.
Revisit if a third long-running process gets added later; two is the reasonable ceiling for "just
write the script."
**Why migrations run from the entrypoint, contradicting what apps/api/Dockerfile's own comment
used to say** ("Migrations run as an explicit step before this in deploy.yml... never from the
entrypoint, so a failed migration fails the deploy visibly instead of crash-looping here"): that
comment described the 3-container Postgres plan, where a separate `run --rm api alembic upgrade
head` step existed *before* `compose up -d`. A single container has nowhere else to put that step.
The property it was protecting — a failed migration must be visible, not silently served — still
holds: `set -e` means the script exits non-zero on migration failure, so the container never starts
serving traffic and shows as exited/restarting in `docker ps`/Unraid, which is the same visibility
by a different mechanism. What's genuinely lost is the *old* mechanism's failure mode of "the
previous version keeps running while the bad migration is investigated" — a single container that
fails to start migrations has no previous version still up. Acceptable for a single-instance
home-lab deployment; would need reconsidering (e.g. a blue/green swap) if this ever needed
zero-downtime deploys.
**Why the Caddy binary is copied from `caddy:2` rather than using a Caddy base image:** the runtime
needs both Python (for uvicorn) and Caddy; picking either official base image as the starting
point means installing the other stack into it by hand. Caddy's official images are a single
statically-linked Go binary with no CGO, so `COPY --from=caddy:2 /usr/bin/caddy /usr/bin/caddy`
into a `python:3.12-slim` base is the documented, standard way to get both without a second
package manager or a source build.
**Why an Unraid template file, not just documentation:** the earlier decision (in-session) was to
move configuration out of a `.env` file and into fields the Unraid web UI can fill in — a plain env
var table in a README doesn't do that by itself, since Unraid still needs a `Config`-tagged XML
entry per field to render one. `deploy/unraid-template.xml` is that; every field stays hand-editable
in the UI afterward regardless of what the template pre-fills, so getting a default slightly wrong
here isn't load-bearing.
**What this doesn't do:** `.gitea/workflows/release.yml` builds and pushes the image to the Gitea
registry; it does not SSH into the Unraid host and recreate the running container. Rolling a new
image out is a manual/Unraid-side action (pull + Apply, or Unraid's own update check), not
something CI does unattended — consistent with treating "affects a shared, already-running system"
as something a human triggers, not automation.
---
## Deliberately deferred