Author SHA1 Message Date
BBergleandClaude Sonnet 5 6b0f28cf74 docs(deploy): note the /data uid/gid-mismatch trap on first start
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 14s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 54s
Hit this deploying to the real Unraid host: the container runs as a fixed
non-root uid/gid (999), not root and not Unraid's usual nobody:users
(99:100). A freshly-created appdata directory is owned by nobody:users with
no write access for anyone else, so the container starts but uvicorn fails
immediately with "unable to open database file" — not obvious from the
error alone, worth documenting once rather than re-debugging it later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 21:08:03 -04:00
BBergleandClaude Sonnet 5 45719f284c feat(deploy): build+push release image on every merge to main
CI / Repo hygiene (pull_request) Successful in 3s
CI / Web (lint, typecheck, build) (pull_request) Successful in 22s
CI / Migrations reversible (pull_request) Successful in 10s
CI / API (lint, types, tests) (pull_request) Successful in 1m4s
Was tag-push-or-manual-dispatch only. Adds a push:main trigger so main stays
continuously deployable without needing a version tag for every change.

Also fixes a real bug this surfaced while testing the D17 registry-TLS fix:
the old tag logic unconditionally retagged :latest on every run, including
manual test dispatches off a feature branch — one such dispatch, done while
verifying the previous commit, silently overwrote :latest with a
feature-branch build. Tag resolution now only moves :latest on an actual
main push or a version tag; a manual dispatch gets its own
manual-<timestamp>-<sha> tag and leaves :latest alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 20:57:52 -04:00
BBergleandClaude Sonnet 5 a114a7d3d8 fix(deploy): push through a TLS-terminating proxy, not raw Gitea HTTP
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 16s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 54s
release.yml's first real run failed: docker/login-action against
192.168.0.3:3000 hit "server gave HTTP response to HTTPS client" — Docker
refuses any non-localhost registry over plain HTTP by default, so this was
never actually a workflow bug.

Rejected insecure-registries in daemon.json after reading this Unraid host's
own rc.docker script: applying it needs a full dockerd restart, and with
Live Restore disabled here, that stops every one of the ~40 other containers
on the box first. Also rejected a real Let's Encrypt cert on a public
bbergle.com subdomain — this host's other subdomains are Cloudflare-proxied,
which would terminate TLS at Cloudflare's edge and never reach our own cert
at all.

Chosen instead, scoped to touch nothing already working: a self-signed cert
for registry.bbergle.com behind a new NPMplus proxy host (found its real
HTTPS port, 9537, by reading `docker port NPMplus` rather than assuming 443,
which is a different nginx process on this box entirely); an /etc/hosts
entry on the Unraid host so only that host needs to resolve the name (no
DNS record, no router/NAT dependency); and its CA dropped into
/etc/docker/certs.d, which Docker's own docs confirm is read per-connection
with no daemon restart required. Also pins buildx to driver: docker instead
of setup-buildx-action's default docker-container driver, which runs an
isolated builder that doesn't see /etc/docker/certs.d and would have quietly
defeated all of the above.

Full record, including what was rejected and why, in docs/DECISIONS.md D17.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 20:52:56 -04:00
BBergleandClaude Sonnet 5 32037b1190 fix(deploy): default the Unraid template's host port off 8080
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 13s
CI / Migrations reversible (pull_request) Successful in 5s
CI / API (lint, types, tests) (pull_request) Successful in 54s
8080 is already bound by qBittorrent on the actual Unraid host this gets
deployed to (found while placing the template for real) — defaulted to 8090
instead. Purely a template default; the container's own internal port is
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2ZKeWkZV7ehf7fivrAkkG
2026-09-21 20:21:01 -04:00
BBergle 70e0182177 Merge pull request 'chore(deploy): single-container Dockerfile, Caddy, and Unraid template' (#6) from chore/deploy-single-container into main
CI / Repo hygiene (push) Successful in 2s
CI / Web (lint, typecheck, build) (push) Successful in 14s
CI / Migrations reversible (push) Successful in 6s
CI / API (lint, types, tests) (push) Successful in 52s
Release image / Build and push single-container image (push) Failing after 1m42s
Reviewed-on: #6
2026-09-21 15:58:43 -04:00
BBergleandClaude Sonnet 5 6c48000d7b 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
2026-09-21 15:55:50 -04:00
BBergle 9fa50cb2ea Merge pull request 'refactor(api): move from Postgres+RLS to single-engine SQLite' (#5) from refactor/sqlite-single-container into main
CI / Repo hygiene (push) Successful in 2s
CI / Web (lint, typecheck, build) (push) Successful in 14s
CI / Migrations reversible (push) Successful in 5s
CI / API (lint, types, tests) (push) Successful in 53s
Reviewed-on: #5
2026-09-21 15:44:54 -04:00
12 changed files with 480 additions and 89 deletions
+15
View File
@@ -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
+72
View File
@@ -0,0 +1,72 @@
name: Release image
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
jobs:
build-and-push:
name: Build and push single-container image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# driver: docker (not the action's default docker-container driver) so buildx reuses the
# host's own dockerd instead of spinning up an isolated builder container — the isolated
# one doesn't see the host's /etc/docker/certs.d, which is how the login step below trusts
# the registry's self-signed cert (docs/DECISIONS.md D17). We don't need multi-platform
# builds, so nothing the docker-container driver offers is actually lost here.
- uses: docker/setup-buildx-action@v3
with:
driver: docker
# 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.
#
# registry.bbergle.com:9537, not the raw 192.168.0.3:3000 Gitea talks HTTP on directly —
# Docker refuses any non-localhost registry over plain HTTP by default. This hostname is an
# NPMplus proxy host in front of Gitea's registry, terminating TLS with a self-signed cert;
# the runner host trusts it via /etc/docker/certs.d/registry.bbergle.com:9537/ca.crt (not
# committed here — host-local trust material, docs/DECISIONS.md D17 has the full setup).
- uses: docker/login-action@v3
with:
registry: registry.bbergle.com:9537
username: BBergle
password: ${{ secrets.REGISTRY_TOKEN }}
# `latest` should only ever mean "what's actually on main" (or a tagged release) — not
# whatever a manual test dispatch off some feature branch happened to build. Learned the
# hard way: a manual dispatch off this very branch, while verifying the fix above, silently
# overwrote `latest` under the old unconditional-tags logic. Building the full tag list here
# in bash (rather than a conditional expression inline in the tags: block below) means there's
# never a blank line for build-push-action to choke on when latest isn't included.
- name: Resolve image tags
id: tag
run: |
IMG=registry.bbergle.com:9537/bbergle/bike-app
if [ "${{ gitea.ref_type }}" = "tag" ]; then
VALUE="${{ gitea.ref_name }}"
UPDATE_LATEST=true
elif [ "${{ gitea.ref_name }}" = "main" ] && [ "${{ gitea.event_name }}" = "push" ]; then
VALUE="main-$(git rev-parse --short HEAD)"
UPDATE_LATEST=true
else
VALUE="manual-$(date -u +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)"
UPDATE_LATEST=false
fi
{
echo "tags<<EOF"
echo "$IMG:$VALUE"
[ "$UPDATE_LATEST" = true ] && echo "$IMG:latest"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.tag.outputs.tags }}
+3 -2
View File
@@ -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/api/ Python 3.12 / FastAPI / SQLAlchemy async / Alembic
apps/web/ SvelteKit static SPA (installable PWA) apps/web/ SvelteKit static SPA (installable PWA)
packages/openapi/ openapi.json — COMMITTED contract artefact, CI enforces it matches the code packages/openapi/ openapi.json — COMMITTED contract artefact, CI enforces it matches the code
deploy/ single-container Dockerfile, Caddyfile, systemd units, backup scripts — Dockerfile single-container build (root, not deploy/ — needs both apps/api and apps/web
see docs/DECISIONS.md D15 for why this isn't docker-compose 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 docs/ plan, decisions, research
scripts/ repo tooling (PR helpers, etc.) scripts/ repo tooling (PR helpers, etc.)
.gitea/workflows/ CI .gitea/workflows/ CI
+73
View File
@@ -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"]
-31
View File
@@ -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"]
-36
View File
@@ -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:<tag> /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.
+7 -19
View File
@@ -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` - **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. 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 There is no `apps/web/Dockerfile` anymore. Per D15/D16 (`docs/DECISIONS.md`), the whole app ships
artifact — see the comment block at the top of `Dockerfile` for the full rationale. There is no as one container, so building this SPA is a stage in the root `Dockerfile`
Node runtime in production; Caddy serves the built files directly and proxies `/api/*` to the API (`web-builder`, `apps/web` as its `COPY` source), not a standalone image — the built output
container (`docs/PLAN.md`, "Service topology"). This image is never run as a long-lived container. (`/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.
Build it with the `apps/web` directory as context: See `deploy/README.md` for the actual single-container build/run instructions.
```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.
+31
View File
@@ -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
}
}
+102 -1
View File
@@ -1 +1,102 @@
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://<host>: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. |
The container runs as a fixed non-root user (uid/gid `999`), not root and not Unraid's usual
`nobody:users` (99:100). If `/data`'s host directory doesn't already exist, Docker/Unraid creates
it owned by `nobody:users` with no write access for other users — the container starts, but
uvicorn fails immediately with `sqlite3.OperationalError: unable to open database file`, since it
can't create the SQLite file inside a directory it can't write to. Fix once, before first start:
`chown -R 999:999 <host path>` (e.g. `/mnt/user/appdata/velodrome` on Unraid).
## 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 at `registry.bbergle.com:9537/bbergle/bike-app` on every push to `main` (tagged
`main-<short-sha>`, and `latest`), on a `v*` tag push (tagged with the tag name, and `latest`), or
on manual `workflow_dispatch` (tagged `manual-<timestamp>-<short-sha>` only — a manual dispatch
never moves `latest`, so testing a feature branch can't clobber what's actually deployable). Not
`192.168.0.3:3000` (Gitea's own plain-HTTP address) directly — Docker
refuses any non-localhost registry over plain HTTP by default, so `registry.bbergle.com:9537` is
an NPMplus proxy host in front of Gitea's registry that terminates TLS with a self-signed cert.
See `docs/DECISIONS.md` D17 for the full setup (cert, NPMplus proxy host, `certs.d` trust, and the
buildx driver change this required) — none of it is committed here, since it's host-local trust
material and NPMplus config, not something this repo can or should own.
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.
+32
View File
@@ -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"
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0"?>
<!--
Unraid Docker "Template" for Community Applications' Add-Container form. This is what turns the
env vars in the table below into fillable fields in the Unraid web UI instead of a .env file —
see docs/DECISIONS.md D16 for why this exists.
Import: Docker tab -> Add Container -> Template drop-down -> pick this file (or paste the
"Template" URL if this repo is served over http from the Gitea instance). Every field is also
editable by hand afterward; nothing here is load-bearing beyond being a starting point.
-->
<Container version="2">
<Name>velodrome</Name>
<Repository>registry.bbergle.com:9537/bbergle/bike-app:latest</Repository>
<Registry>http://192.168.0.3:3000/BBergle/-/packages/container/bike-app</Registry>
<Network>bridge</Network>
<Privileged>false</Privileged>
<Support>https://192.168.0.3:3000/BBergle/bike-app/issues</Support>
<Project>http://192.168.0.3:3000/BBergle/bike-app</Project>
<Overview>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/D17) in the repo for the design.</Overview>
<Category>Productivity:</Category>
<WebUI>http://[IP]:[PORT:8090]/</WebUI>
<Icon/>
<ExtraParams/>
<PostArgs/>
<CPUset/>
<DateInstalled/>
<DonateText/>
<DonateLink/>
<Description>Self-hosted cycling app: Bryton ride sync, mileage tracking, spare-parts inventory, maintenance reminders.</Description>
<Config Name="Web UI Port" Target="8080" Default="8090" Mode="tcp" Description="Host port mapped to the container's internal 8080. Defaulted off 8080 since that's already taken by qBittorrent on this host — check for a free port before changing it. Put a reverse proxy with TLS in front of this — the container itself only ever serves plain HTTP (docs/PLAN.md 'Service topology')." Type="Port" Display="always" Required="true" Mask="false">8090</Config>
<Config Name="Data" Target="/data" Default="/mnt/user/appdata/velodrome" Mode="rw" Description="The SQLite database file (and, in a later phase, the raw-file blob store) live here. This is the only thing worth backing up." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/velodrome</Config>
<Config Name="VELODROME_PUBLIC_URL" Target="VELODROME_PUBLIC_URL" Default="" Mode="" Description="The externally-visible URL this instance is reachable at, e.g. https://bikes.example.com. Must match exactly what's in the browser's address bar — it's checked against the Origin header on cookie-authenticated requests to stop cross-site request forgery." Type="Variable" Display="always" Required="true" Mask="false"></Config>
<Config Name="VELODROME_SECRET_KEY" Target="VELODROME_SECRET_KEY" Default="" Mode="" Description="A random secret, 32+ bytes. Generate one with: openssl rand -hex 32. The image ships an insecure development placeholder — always override this before exposing the container to anything." Type="Variable" Display="always" Required="true" Mask="true"></Config>
<Config Name="VELODROME_ENVIRONMENT" Target="VELODROME_ENVIRONMENT" Default="production" Mode="" Description="development | test | production. Gates the session cookie's Secure flag — leave this as production for any deployment reachable over HTTPS." Type="Variable" Display="always" Required="true" Mask="false">production</Config>
<Config Name="VELODROME_SESSION_TTL_DAYS" Target="VELODROME_SESSION_TTL_DAYS" Default="90" Mode="" Description="Days before a login session expires and re-authentication is required." Type="Variable" Display="advanced" Required="false" Mask="false">90</Config>
<Config Name="VELODROME_SESSION_COOKIE_NAME" Target="VELODROME_SESSION_COOKIE_NAME" Default="vd_session" Mode="" Description="Name of the session cookie. No reason to change this unless it collides with another app on the same domain." Type="Variable" Display="advanced" Required="false" Mask="false">vd_session</Config>
</Container>
+108
View File
@@ -181,6 +181,114 @@ 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; (D6, opaque bearer tokens) is unaffected. FastAPI/SQLAlchemy/Alembic stay exactly as chosen in D4;
only the database engine underneath them changed. 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.
### D17 — Registry TLS: self-signed cert behind NPMplus, not `insecure-registries`, not a real domain
**Problem:** `release.yml`'s first real run failed — `docker/login-action` against
`192.168.0.3:3000` (Gitea's plain-HTTP address) hit `server gave HTTP response to HTTPS client`.
Docker refuses TLS-less registries by default; this was never a workflow misconfiguration, it's
expected Docker behaviour for any non-localhost registry.
**Rejected: `insecure-registries` in `daemon.json`.** The obvious fix. Rejected after actually
reading `/etc/rc.d/rc.docker` on the Unraid host rather than assuming: applying a `daemon.json`
change requires a full `dockerd` restart, and (with `Live Restore` disabled on this host) both
Unraid's own restart path *and* a raw `kill` of `dockerd` stop every one of the ~40 other
containers running on the box first, as part of the restart/shutdown sequence — Plex, Home
Assistant, Vaultwarden, everything. Correct fix for the narrow problem, unacceptable blast radius
for this specific host.
**Rejected: a real Let's Encrypt cert on a new `bbergle.com` subdomain routed publicly.** The
user's other NPMplus-fronted subdomains resolve through Cloudflare's proxy (orange-cloud), not
directly to the home IP. A Cloudflare-proxied hostname would have terminated TLS at Cloudflare's
edge with Cloudflare's own cert, never reaching our self-signed cert or NPMplus's own TLS
config at all — the entire trust chain would depend on Cloudflare's origin SSL mode, and likely on
firewall rules restricting port 443 to Cloudflare's IP ranges, neither of which this problem
needed to involve.
**Chosen:** a small, fully self-contained fix, scoped to touch nothing already working:
- A 10-year self-signed cert for `registry.bbergle.com` (SAN-only, no real domain dependency).
- An NPMplus proxy host (`registry.bbergle.com` -> `192.168.0.3:3000` over plain HTTP internally)
terminating TLS with that cert, on NPMplus's existing HTTPS port (`9537` on this host — found by
reading `docker port NPMplus` rather than assuming 443, which is a *different* nginx process on
this box entirely).
- `/etc/hosts` on the Unraid host mapping `registry.bbergle.com` -> `192.168.0.103` (itself) —
chosen over a real DNS record specifically because the only client that ever needs to resolve
this hostname is the Unraid host's own `dockerd` (Gitea Actions runs in DooD mode against that
same host's Docker socket). This sidesteps Cloudflare, the router's NAT/hairpin behaviour, and
any port-forwarding question entirely — verified separately that hairpin NAT works by default on
this user's UniFi gateway, but it turned out to be unnecessary for this fix regardless.
- `/etc/docker/certs.d/registry.bbergle.com:9537/ca.crt` on the Unraid host, trusting that cert for
that host:port specifically. Confirmed (Docker's own docs) that `certs.d` is read per-connection,
not baked in at daemon start — no `dockerd` restart, no impact on any other container.
- `docker/setup-buildx-action@v3` pinned to `driver: docker` in `release.yml` instead of its
default `docker-container` driver — the default runs BuildKit in an isolated builder container
that does not see the host's `/etc/docker/certs.d`, which would have silently defeated the whole
point of the trust setup above. We don't build multi-platform images, so nothing the
`docker-container` driver offers is actually needed here.
**Not persisted across a reboot, deliberately, for now:** neither the `/etc/hosts` line nor the
`certs.d` file are wired into `/boot/config/go` — both live under `/`, which Unraid rebuilds fresh
from `/boot` on every boot. Raised explicitly rather than assumed: the user was (rightly) wary of
hand-editing anything under `/boot` after an earlier, unrelated discussion of what a broken `go`
script could do to boot. Persisting this is a five-minute follow-up (append two lines to `go`) once
they're ready to make that call deliberately, not bundled into this fix.
**What's unaffected:** Gitea's own web UI, git remote, and API — all still plain
`http://192.168.0.3:3000`, exactly as CLAUDE.md documents. NPMplus's existing public proxy hosts
and certs (`vaultwarden.bbergle.com` etc.) — untouched, new proxy host only. No other container on
the Unraid host was restarted, reconfigured, or otherwise touched to make this work.
--- ---
## Deliberately deferred ## Deliberately deferred