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
+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"]