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
apps/web — Velodrome PWA shell
SvelteKit static SPA, installable PWA. Talks to the API at /api/v1 — same-origin in production
(Caddy proxies it), same-origin locally via the dev proxy in vite.config.ts. See
docs/PLAN.md ("Stack", "The PWA decision") for why this shape was chosen; this file is just the
how.
Dev commands
pnpm install
pnpm run dev # vite dev server, proxies /api -> http://localhost:8000
pnpm run check # svelte-kit sync + svelte-check
pnpm run lint # prettier --check + eslint
pnpm run format # prettier --write
pnpm run build # production build -> build/
pnpm run preview # serve the production build locally
pnpm run lint, pnpm run check, and pnpm run build are exactly the three steps CI's web job
runs (.gitea/workflows/ci.yml) — keep those script names stable.
Requires Node 22 and pnpm (via corepack). The service worker is disabled in dev
(devOptions.enabled: false in vite.config.ts) — dev already has instant HMR, and a dev-mode SW
mostly just causes "why isn't my change showing up" confusion.
Shape of the app
-
adapter-static, SPA mode, no prerendering.
src/routes/+layout.tssetsexport const ssr = false, andvite.config.tsconfigures@sveltejs/adapter-staticwithfallback: 'index.html'. This is a client-only app: there's no Node process in production (see "Dockerfile" below), so every route has to resolve client-side against a single served shell, not be prerendered. -
Auth.
src/lib/stores/auth.tsis a tiny Svelte store backed byGET /api/v1/auth/me.src/routes/+layout.sveltecallsauth.refresh()once on load;/and/loginreact to the store to decide what to show/redirect to. Login posts to/api/v1/auth/login(src/lib/api/auth.ts) and relies on the API setting an HttpOnly cookie — the app never touches the token directly. -
PWA / service worker.
@vite-pwa/sveltekitis not used here — see the long comment block at the top ofvite.config.tsfor why (itsinjectManifestbuild expects SvelteKit's own built-insrc/service-worker.tsconvention to have already transpiled the file, but that native build only permits importing SvelteKit's own three virtual modules and hard-rejectsworkbox-*imports). We use the basevite-plugin-pwa(VitePWA(...)) instead, ininjectManifestmode, and explicitly disable SvelteKit's native service-worker convention (kit.files.serviceWorkerpointed at a nonexistent path) so only vite-plugin-pwa's build runs.injectManifest, notgenerateSW: the caching policy is hand-written insrc/service-worker.tsusing Workbox primitives directly, because the policy needs to say "never, ever cache/api/*" — more precisely thangenerateSW's declarative config expresses. The policy, straight fromdocs/PLAN.md's caching table:- Precache the injected build manifest (hashed JS/CSS/icons/
manifest.webmanifest), plus a synthetic entry for the SPA fallbackindex.html(see theswIndexRevisioncomment invite.config.ts— the realbuild/index.htmldoesn't exist yet when the SW is built, since the adapter writes it afterward, so its precache revision is a per-build-invocation timestamp instead of a content hash). - Navigations: cache-first, served from the precached
index.htmlviaNavigationRoute(createHandlerBoundToURL('index.html'))— the app opens instantly and works offline. /api/*:NetworkOnly, unconditionally. Never cached, per CLAUDE.md's invariants.- Nothing else yet — tile/stream caching is a later phase; the file is structured so those are
additional
registerRoute()calls, not a rewrite.
skipWaiting()+clientsClaim()run immediately.src/lib/components/UpdateToast.svelteusesvirtual:pwa-register/svelte'suseRegisterSW()to show a "New version available — reload" toast instead of silently swapping the SW under the user. - Precache the injected build manifest (hashed JS/CSS/icons/
-
Manifest / icons.
static/manifest.webmanifestis hand-written (not plugin-generated) and linked explicitly fromsrc/app.html, along with theapple-touch-icon,apple-mobile-web-app-capablemeta, andviewport-fit=cover(paired withenv(safe-area-inset-*)padding in the root layout) for iOS standalone mode. Icons instatic/icons/are placeholder solid-color PNGs — fine for Phase 0, swap them for real artwork later. -
iOS install banner.
src/lib/components/IosInstallBanner.svelte— informational only ("Add to Home Screen" instructions), shown when not already standalone and the UA looks like iOS Safari. Dismissible per session (sessionStorage), reappears next session until actually installed. NoNotification.requestPermission()here — that's gated behind standalone mode in a later phase, perdocs/PLAN.md's PWA-decision table. -
Styling. Plain CSS (
src/app.css), CSS custom properties for theming,prefers-color-schemefor dark mode. No Tailwind — kept the dependency surface small for this scaffolding phase.
Docker build
There is no apps/web/Dockerfile anymore. Per D15/D16 (docs/DECISIONS.md), the whole app ships
as one container, so building this SPA is a stage in the root Dockerfile
(web-builder, apps/web as its COPY source), not a standalone image — the built output
(/app/build) is copied straight into the runtime stage at /srv/web, which is what the root
Dockerfile's Caddy config (deploy/Caddyfile) serves. There is no Node runtime in production.
See deploy/README.md for the actual single-container build/run instructions.