diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4a41451 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitea +docs +scripts +**/node_modules +**/.venv +**/__pycache__ +**/*.pyc +apps/web/build +apps/web/.svelte-kit +apps/api/.pytest_cache +apps/api/.mypy_cache +apps/api/.ruff_cache +**/*.db +**/*.db-journal diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..66f6ca4 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release image + +on: + push: + tags: ['v*'] + workflow_dispatch: + +jobs: + build-and-push: + name: Build and push single-container image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + # secrets.GITEA_TOKEN cannot push to the Gitea container registry — a documented Gitea + # limitation, not a misconfiguration (see CLAUDE.md). REGISTRY_TOKEN is a separate PAT with + # package:write, expected to already exist as a repo secret. + - uses: docker/login-action@v3 + with: + registry: 192.168.0.3:3000 + username: BBergle + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Resolve image tag + id: tag + run: | + if [ "${{ gitea.ref_type }}" = "tag" ]; then + echo "value=${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT" + else + echo "value=manual-$(date -u +%Y%m%d%H%M%S)" >> "$GITHUB_OUTPUT" + fi + + - uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + tags: | + 192.168.0.3:3000/bbergle/bike-app:latest + 192.168.0.3:3000/bbergle/bike-app:${{ steps.tag.outputs.value }} diff --git a/CLAUDE.md b/CLAUDE.md index 6deddc6..3118e91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,9 @@ say so and argue it — but don't silently contradict it. apps/api/ Python 3.12 / FastAPI / SQLAlchemy async / Alembic apps/web/ SvelteKit static SPA (installable PWA) packages/openapi/ openapi.json — COMMITTED contract artefact, CI enforces it matches the code -deploy/ single-container Dockerfile, Caddyfile, systemd units, backup scripts — - see docs/DECISIONS.md D15 for why this isn't docker-compose +Dockerfile single-container build (root, not deploy/ — needs both apps/api and apps/web + as build context). See docs/DECISIONS.md D15/D16 for why one container. +deploy/ Caddyfile, entrypoint.sh, unraid-template.xml, systemd backup units docs/ plan, decisions, research scripts/ repo tooling (PR helpers, etc.) .gitea/workflows/ CI diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..017fa86 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,73 @@ +# syntax=docker/dockerfile:1 +# +# Single-container image for Velodrome (docs/DECISIONS.md D15/D16): Caddy serves the SvelteKit +# static SPA and reverse-proxies /api/* to the FastAPI app, both running in the same container +# behind whatever TLS-terminating reverse proxy already exists on the host. Build context is the +# repo root, since this needs both apps/api and apps/web: +# +# docker build -t velodrome . +# +# Replaces apps/api/Dockerfile and apps/web/Dockerfile from the old 4-container compose plan +# (PR #4, closed as superseded) — those built two images meant to run as separate services; +# this builds one. + +# ---- web: produces /app/build, nothing from this stage ends up running ---- +FROM node:22-slim AS web-builder +WORKDIR /app +COPY apps/web/package.json apps/web/pnpm-lock.yaml ./ +RUN corepack enable && corepack prepare pnpm@9 --activate \ + && pnpm install --frozen-lockfile +COPY apps/web . +RUN pnpm run build + +# ---- api: produces the venv at /app/.venv ---- +FROM python:3.12-slim AS api-builder +RUN pip install --no-cache-dir uv +WORKDIR /app +COPY apps/api/pyproject.toml apps/api/uv.lock ./ +# Dependencies first, isolated from source changes, so touching velodrome/ doesn't invalidate +# this layer. +RUN uv sync --frozen --no-install-project --no-dev +COPY apps/api/velodrome ./velodrome +COPY apps/api/alembic ./alembic +COPY apps/api/alembic.ini ./ +RUN uv sync --frozen --no-dev + +# ---- runtime ---- +FROM python:3.12-slim AS runtime + +# Caddy's official images ship a single statically-linked Go binary (no CGO) — copying it out of +# the upstream image is the standard way to get Caddy into a non-Caddy base image without a +# second package manager or a source build. +COPY --from=caddy:2 /usr/bin/caddy /usr/bin/caddy + +# tini is PID 1: reaps zombies and forwards signals correctly to entrypoint.sh's two background +# processes, which a bare `CMD` running a shell script as PID 1 would not do on its own. +RUN apt-get update && apt-get install -y --no-install-recommends tini \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd --system velodrome && useradd --system --gid velodrome --create-home velodrome + +WORKDIR /app +COPY --from=api-builder --chown=velodrome:velodrome /app /app +COPY --from=web-builder --chown=velodrome:velodrome /app/build /srv/web +COPY --chown=velodrome:velodrome deploy/Caddyfile /etc/caddy/Caddyfile +COPY --chown=velodrome:velodrome deploy/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +ENV PATH="/app/.venv/bin:$PATH" +# Absolute path into the mounted volume below. See deploy/README.md for the full env var table — +# this is the one variable NOT meant to be overridden per-deployment, since /data is the contract +# with the volume mount, not a per-instance setting. +ENV VELODROME_DATABASE_URL="sqlite+aiosqlite:////data/velodrome.db" + +RUN mkdir -p /data && chown velodrome:velodrome /data + +USER velodrome +VOLUME ["/data"] +# Non-privileged port: Caddy needs no root/setcap here, and TLS termination is the host reverse +# proxy's job (docs/PLAN.md "Service topology"), not this container's. +EXPOSE 8080 + +ENTRYPOINT ["tini", "--"] +CMD ["/app/entrypoint.sh"] diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile deleted file mode 100644 index 4398ff3..0000000 --- a/apps/api/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -# syntax=docker/dockerfile:1 -FROM python:3.12-slim AS builder - -RUN pip install --no-cache-dir uv - -WORKDIR /app -COPY pyproject.toml uv.lock ./ -# Split into two syncs so dependency installation caches independently of source changes: this -# first one installs only dependencies (--no-install-project), so touching velodrome/ doesn't -# invalidate this layer. -RUN uv sync --frozen --no-install-project --no-dev - -COPY velodrome ./velodrome -COPY alembic ./alembic -COPY alembic.ini ./ -# Now install the project itself into the same venv. -RUN uv sync --frozen --no-dev - -FROM python:3.12-slim AS runtime - -RUN groupadd --system velodrome && useradd --system --gid velodrome --create-home velodrome -WORKDIR /app -COPY --from=builder /app /app -ENV PATH="/app/.venv/bin:$PATH" -USER velodrome - -EXPOSE 8000 - -# Migrations run as an explicit step before this in deploy.yml (see docs/PLAN.md) — never from -# the entrypoint, so a failed migration fails the deploy visibly instead of crash-looping here. -CMD ["uvicorn", "velodrome.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile deleted file mode 100644 index 61efc0a..0000000 --- a/apps/web/Dockerfile +++ /dev/null @@ -1,36 +0,0 @@ -# This image's ONLY purpose is to produce /app/build — the static SPA output -# (HTML/CSS/JS/manifest/service worker). There is no Node runtime in -# production: per docs/PLAN.md's "Stack" and "The PWA decision", Caddy serves -# these files directly and proxies /api/* to the FastAPI backend. This image -# is never run as a long-lived container in production. -# -# Build context: this directory (apps/web), e.g. `docker build -f -# apps/web/Dockerfile apps/web`. -# -# How deploy/ is expected to consume this: build this image, then copy its -# /app/build contents out into a location the `caddy` service in -# deploy/docker-compose.yml bind-mounts — either via a multi-stage -# `COPY --from=bennybergle/velodrome-web: /app/build /srv/web` in the -# Caddy image build, or with a one-shot `docker create` + `docker cp` / -# `docker run --rm -v ...` step in the deploy pipeline that dumps /app/build -# into a named volume or bind-mounted host directory before `caddy` starts. -# Picked this over a scratch/busybox "artifact-holder" final stage because a -# single builder stage is simpler to reason about and there's nothing here -# that needs to run — the consumer only ever needs the files, not a container. -# The deploy/ agent may adjust this to whatever's simplest for the compose -# setup; this is just the contract (build → /app/build). - -FROM node:22-slim AS builder - -WORKDIR /app - -# Install dependencies first, isolated from source changes, for layer caching. -COPY package.json pnpm-lock.yaml ./ -RUN corepack enable && corepack prepare pnpm@9 --activate \ - && pnpm install --frozen-lockfile - -COPY . . -RUN pnpm run build - -# Nothing further: /app/build is the artifact. No CMD/ENTRYPOINT — this image -# is not meant to be run. diff --git a/apps/web/README.md b/apps/web/README.md index 5df5bf1..5564eb2 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -77,23 +77,11 @@ false`, and `vite.config.ts` configures `@sveltejs/adapter-static` with `fallbac - **Styling.** Plain CSS (`src/app.css`), CSS custom properties for theming, `prefers-color-scheme` for dark mode. No Tailwind — kept the dependency surface small for this scaffolding phase. -## Dockerfile +## Docker build -This image's **only** purpose is to produce `/app/build` (the static SPA output) as a buildable -artifact — see the comment block at the top of `Dockerfile` for the full rationale. There is no -Node runtime in production; Caddy serves the built files directly and proxies `/api/*` to the API -container (`docs/PLAN.md`, "Service topology"). This image is never run as a long-lived container. - -Build it with the `apps/web` directory as context: - -```sh -docker build -f apps/web/Dockerfile -t velodrome-web-build apps/web -``` - -How `deploy/` is expected to consume it (my assumption — the `deploy/` work is happening in a -parallel worktree, so this may get adjusted there): a multi-stage `COPY --from=velodrome-web-build -/app/build /srv/web` in whatever image serves Caddy, or a one-shot `docker create` + -`docker cp` / bind-mount step in the deploy pipeline that populates the volume Caddy reads from -before it starts. Went with a single plain builder stage (no `scratch`/`busybox` artifact-holder -final stage) because nothing here needs to _run_ — the only thing anyone needs from this image is -the files in `/app/build`, and a second stage would add complexity without adding anything. +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. diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..0926888 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,31 @@ +# Single-container Caddy config (docs/DECISIONS.md D15). Serves the static SvelteKit build and +# reverse-proxies /api/* to uvicorn on loopback — the same split apps/web/vite.config.ts's dev +# proxy describes, just as Caddy directives instead of Vite's dev-server proxy. +{ + admin off + # Explicit, not just implied by using a bare port below: this container is never the TLS + # terminator (docs/PLAN.md "Service topology" — the host's existing reverse proxy is), so + # Caddy must never attempt to provision a certificate for whatever it's fronted by. + auto_https off +} + +:8080 { + encode gzip + + log { + output stdout + } + + handle /api/* { + reverse_proxy 127.0.0.1:8000 + } + + # SPA fallback matching apps/web/vite.config.ts's `adapter({ fallback: 'index.html' })`: + # any path that isn't a real built file resolves to index.html so client-side routing works + # on a hard refresh / direct link. + handle { + root * /srv/web + try_files {path} /index.html + file_server + } +} diff --git a/deploy/README.md b/deploy/README.md index 138b89b..8405778 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1 +1,86 @@ -docker-compose, Caddyfile, systemd backup units. Not yet written — Phase 0. +# 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://:8080/api/v1/healthz` should return `{"status": "ok"}` once it's up. + +## 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. | + +## 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 (`192.168.0.3:3000/bbergle/bike-app`) on a `v*` tag push, or on manual +`workflow_dispatch`. 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. diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh new file mode 100644 index 0000000..3a174d1 --- /dev/null +++ b/deploy/entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Single-container entrypoint (docs/DECISIONS.md D16). Two responsibilities: +# +# 1. Run migrations before serving anything. There's no separate "run migrations, then start the +# app" step in front of this container the way docs/PLAN.md's original deploy.yml had one for +# the 3-container Postgres stack (see apps/api/Dockerfile's history) — a single container has +# nowhere else to put that step. `set -e` means a failed migration exits non-zero here, which +# still fails startup visibly (Docker/Unraid shows the container as exited/restarting) instead +# of silently serving a broken app; that's the property the separate step existed to protect, +# preserved by a different mechanism now that there's only one container to do it in. +# 2. Run uvicorn and Caddy as two background processes and tie their lifetimes together: if either +# one dies, kill the other and exit with its status, so Docker/Unraid restarts the whole +# container. Two half-alive processes (API up, web serving stale/nothing, or vice versa) is a +# worse failure mode than a clean restart. +set -euo pipefail + +alembic -c /app/alembic.ini upgrade head + +# uvicorn binds loopback only — Caddy is the sole process with an exposed port, and the sole +# thing that talks to uvicorn (see deploy/Caddyfile's reverse_proxy target). +uvicorn velodrome.app:app --host 127.0.0.1 --port 8000 & +API_PID=$! + +caddy run --config /etc/caddy/Caddyfile --adapter caddyfile & +CADDY_PID=$! + +trap 'kill -TERM "$API_PID" "$CADDY_PID" 2>/dev/null || true' TERM INT + +wait -n "$API_PID" "$CADDY_PID" +EXIT_CODE=$? +kill -TERM "$API_PID" "$CADDY_PID" 2>/dev/null || true +exit "$EXIT_CODE" diff --git a/deploy/unraid-template.xml b/deploy/unraid-template.xml new file mode 100644 index 0000000..cffe4eb --- /dev/null +++ b/deploy/unraid-template.xml @@ -0,0 +1,37 @@ + + + + velodrome + 192.168.0.3:3000/bbergle/bike-app:latest + http://192.168.0.3:3000/BBergle/-/packages/container/bike-app + bridge + false + https://192.168.0.3:3000/BBergle/bike-app/issues + http://192.168.0.3:3000/BBergle/bike-app + Self-hosted cycling app: Bryton Rider 650 ride sync, mileage tracking, spare-parts inventory, and maintenance reminders. One container: Caddy + the FastAPI app + a SQLite database file on the Data path below. See docs/PLAN.md and docs/DECISIONS.md (D15/D16) in the repo for the design. + Productivity: + http://[IP]:[PORT:8080]/ + + + + + + + + Self-hosted cycling app: Bryton ride sync, mileage tracking, spare-parts inventory, maintenance reminders. + 8080 + /mnt/user/appdata/velodrome + + + production + 90 + vd_session + diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index ed5dc53..93ab530 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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