From 597c83649cee31cb1b2e9c939c99f349e3657573 Mon Sep 17 00:00:00 2001 From: Travis Herbranson Date: Mon, 25 May 2026 11:48:37 -0400 Subject: [PATCH] deploy/web: containerized FastAPI UI on the homelab docker network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dockerfile, compose.yml, env template, and runbook for the second-brain web container. Targets herbys-dev (10.0.21.207); Traefik (file-provider on 10.0.11.20) reaches the published host port at 10.0.21.207:8080. Image: - python:3.12-slim + the official uv binary copied from the upstream image, plus apt-installed git + ca-certificates so the Gitea VCS pin for embedding-chunking resolves at build time. - uv sync --frozen --no-dev --no-install-project, source copy, then a second uv sync to install the project itself. Two-step so the lock install layer caches independently of source edits. - No ffmpeg / claude CLI / faster-whisper — web role doesn't need any of them. Extraction runs on the dev host's CLI; transcription on the tower. - Drops to uid 1000 (`app`) before CMD. Uvicorn binds 0.0.0.0:8000 inside the container, with --proxy-headers + --forwarded-allow-ips=* so Traefik's X-Forwarded-* survive. compose.yml: - Joins the existing external `homelab` bridge network so the container reaches homelab-postgres:5432 and ollama:11434 by service DNS. - Publishes the uvicorn port at 10.0.21.207:8080 (LAN IP bound, not 0.0.0.0) for Traefik on the separate VM to reach. NO traefik.* labels — file-provider Traefik can't read them. - env_file: .env (0600, gitignored) — SECOND_BRAIN_DATABASE_URL points at homelab-postgres:5432 (containerised), NOT the host's 127.0.0.1:5433 port-map. - restart: unless-stopped. README.md: - Build + bring-up commands. - SECURITY note: no SSO / no CSRF / mutating endpoints — Traefik route must be LAN-only for now (Travis's call). - The two infra steps Travis applies himself, with ready-to-paste snippets: - Knot DNS: brain.herbylab.dev → 10.0.11.20. - Traefik dynamic config: file-provider router + service block. - Verification checklist for both before-and-after-DNS states. Live-verified on herbys-dev: container Up, dashboard returns 200 with real status counts from petalbrain, settings save round-trip works, no tracebacks in logs. --- deploy/web/.env.example | 20 +++++ deploy/web/.gitignore | 2 + deploy/web/Dockerfile | 63 ++++++++++++++++ deploy/web/README.md | 159 ++++++++++++++++++++++++++++++++++++++++ deploy/web/compose.yml | 41 +++++++++++ 5 files changed, 285 insertions(+) create mode 100644 deploy/web/.env.example create mode 100644 deploy/web/.gitignore create mode 100644 deploy/web/Dockerfile create mode 100644 deploy/web/README.md create mode 100644 deploy/web/compose.yml diff --git a/deploy/web/.env.example b/deploy/web/.env.example new file mode 100644 index 0000000..fb6729a --- /dev/null +++ b/deploy/web/.env.example @@ -0,0 +1,20 @@ +# deploy/web/.env — runtime secrets for the second-brain-web container. +# +# Copy this to deploy/web/.env, fill in REPLACE_ME, chmod 0600. The real +# .env is gitignored (see deploy/web/.gitignore). Source of truth for +# the lovebug password: /opt/backups/postgres-consolidation/credentials.env +# on herbys-dev (mode 0600, host-only). + +# REQUIRED. Containerised connection — the web container reaches Postgres +# by docker-network DNS name, NOT via the host's 127.0.0.1:5433 publish. +SECOND_BRAIN_DATABASE_URL=postgresql+psycopg://lovebug:REPLACE_ME@homelab-postgres:5432/petalbrain + +# OPTIONAL. The dashboard does not currently embed search queries, but +# the embedding module reads this lazily. Default targets the homelab +# `ollama` container. +OLLAMA_URL=http://ollama:11434 +EMBEDDING_MODEL=nomic-embed-text + +# OPTIONAL. Pool sizing — single-instance web doesn't need much. +# SECOND_BRAIN_DB_POOL_MIN=1 +# SECOND_BRAIN_DB_POOL_MAX=4 diff --git a/deploy/web/.gitignore b/deploy/web/.gitignore new file mode 100644 index 0000000..efdaaaa --- /dev/null +++ b/deploy/web/.gitignore @@ -0,0 +1,2 @@ +# Real .env carries the lovebug Postgres password. Never commit. +.env diff --git a/deploy/web/Dockerfile b/deploy/web/Dockerfile new file mode 100644 index 0000000..ed51e1e --- /dev/null +++ b/deploy/web/Dockerfile @@ -0,0 +1,63 @@ +# second-brain web container — review/dashboard UI only. +# +# Slim by design: this image runs `uvicorn` against second_brain.web.app. +# It does NOT need ffmpeg, the claude CLI, faster-whisper, or any GPU +# tooling. Extraction runs on the dev host's CLI; transcription runs on +# the tower. The web role is just "FastAPI + psycopg + Jinja templates". + +FROM python:3.12-slim AS base + +ENV PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/app/.venv + +# uv is fetched as a static binary from its official image so we don't +# pull a full python stack to install it. +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ + +# `git` is needed at *build* time because pyproject.toml pins +# embedding-chunking to a Gitea VCS source. `ca-certificates` so the +# https handshake validates. Slim image to clean up apt afterwards. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Project metadata first so the layer cache survives source edits. +COPY pyproject.toml uv.lock ./ + +# `--frozen` enforces lockfile parity. `--no-dev` excludes pytest/ruff. +# `--no-install-project` so we don't try to install the (not-yet-copied) +# source tree in this layer. +RUN uv sync --frozen --no-dev --no-install-project + +# Source + runtime config. config/settings.toml.example is committed; a +# real settings.toml gets bind-mounted in by compose at runtime. +COPY src/ ./src/ +COPY config/ ./config/ +COPY alembic/ ./alembic/ +COPY alembic.ini ./alembic.ini + +# Now install the project itself into the venv (puts the `second-brain` +# console script + the second_brain package on PATH). +RUN uv sync --frozen --no-dev + +# Drop privileges. The image doesn't write to the host bind-mounts; the +# settings.toml mount is read-only. +RUN useradd --uid 1000 --create-home --shell /sbin/nologin app \ + && chown -R app:app /app +USER app + +EXPOSE 8000 + +# Bind to all interfaces inside the container — the host port publish +# decides who can reach it from outside. `--proxy-headers` lets Traefik +# (off-box on 10.0.11.20) forward X-Forwarded-* correctly. +CMD ["/app/.venv/bin/uvicorn", \ + "second_brain.web.app:app", \ + "--host", "0.0.0.0", \ + "--port", "8000", \ + "--proxy-headers", \ + "--forwarded-allow-ips=*"] diff --git a/deploy/web/README.md b/deploy/web/README.md new file mode 100644 index 0000000..eef62a5 --- /dev/null +++ b/deploy/web/README.md @@ -0,0 +1,159 @@ +# second-brain web app — deploy notes + +Target host: **herbys-dev** (10.0.21.207). Runs the FastAPI + Jinja UI +under `docker compose`, on the existing `homelab` bridge network so it +can reach `homelab-postgres:5432` and `ollama:11434` by service DNS. + +Traefik (file-provider VM at **10.0.11.20**) handles TLS + the public +hostname `brain.herbylab.dev`. The compose file does NOT carry +`traefik.*` labels — file-provider Traefik can't see them. + +--- + +## ⚠ Security note (no SSO) + +Travis opted **not** to put this behind Authentik for the initial roll-out. +The web app has **no authentication, no CSRF, no rate limiting**, and it +exposes mutating endpoints (accept/reject sources, edit pipeline settings). + +→ The Traefik route should be reachable **from LAN / trusted networks +only**. If you ever publish this past Cloudflare for off-LAN access, gate +it behind the Authentik forward-auth middleware first, or add an +`auth_required` decorator to the FastAPI app. + +--- + +## 1. One-time setup on herbys-dev + +```bash +cd /opt/projects/homelab/second-brain/deploy/web + +# Real env file — paste the lovebug password. +cp .env.example .env +chmod 0600 .env +$EDITOR .env +# SECOND_BRAIN_DATABASE_URL must use host=homelab-postgres (NOT 127.0.0.1). +# Password lives at /opt/backups/postgres-consolidation/credentials.env. + +# Build + start. Compose will join the existing external `homelab` net. +docker compose up -d --build + +# Confirm it's healthy. +docker compose ps +docker compose logs --tail 50 +curl -fsS http://10.0.21.207:8080/dashboard | head +``` + +Day-to-day: + +```bash +docker compose logs -f # tail +docker compose restart # bounce (config reload) +docker compose pull && docker compose up -d # if image were registry-hosted +git pull && docker compose up -d --build # build-on-host update path +``` + +--- + +## 2. INFRA STEPS — Travis applies these manually + +The compose stack is *up* once `docker compose up -d --build` returns. +The **public URL** at `https://brain.herbylab.dev` only resolves after +the two steps below land on their respective infra. They live on +machines outside this repo's authority (Knot on the DNS box; Traefik on +10.0.11.20), so this README documents them rather than executing them. + +### 2a. Knot DNS — A record for `brain.herbylab.dev` + +Add this to the `herbylab.dev` zone file on the Knot host (file path / +include layout matches the existing per-service A records — pick the +spot used for the other `*.herbylab.dev` services): + +```zone +brain.herbylab.dev. 300 IN A 10.0.11.20 +``` + +300s TTL matches the existing convention. Reload Knot: + +```bash +sudo knotc reload +dig +short brain.herbylab.dev @ # should return 10.0.11.20 +``` + +### 2b. Traefik file-provider router on 10.0.11.20 + +Add the snippet below to the Traefik dynamic config (same file the rest +of the `*.herbylab.dev` services live in — file-provider config picks +up changes without a restart): + +```yaml +http: + routers: + second-brain: + rule: "Host(`brain.herbylab.dev`)" + entryPoints: + - websecure + tls: + certResolver: cloudflare + service: second-brain + # No middleware on day one. If/when SSO lands, prepend the + # authentik forward-auth chain here (see other routers for the + # shape) and remove the LAN restriction. + + services: + second-brain: + loadBalancer: + servers: + - url: "http://10.0.21.207:8080" + passHostHeader: true +``` + +Verify on the Traefik host: + +```bash +# Watch for config-reload events: +docker logs traefik --tail 20 -f +# Or hit the API if it's exposed: +curl -fsS http://localhost:8080/api/http/routers/second-brain@file +``` + +Once both 2a and 2b are in place, `https://brain.herbylab.dev/dashboard` +should resolve through Cloudflare → Traefik → 10.0.21.207:8080 → the +`second-brain-web` container. + +--- + +## 3. Verification checklist + +After `docker compose up -d --build`: + +| Check | Command | Expect | +|---|---|---| +| Container up | `docker compose ps` | `second-brain-web` is `Up`, not restarting | +| Startup log clean | `docker compose logs --tail 50` | uvicorn `Started server process` + `Application startup complete`, no tracebacks | +| Local-host reachable | `curl -fsS http://10.0.21.207:8080/dashboard \| head -1` | `` or similar | +| DB reachable | `curl -fsS http://10.0.21.207:8080/dashboard` | dashboard renders status counts (proves the homelab-net path to Postgres) | +| Settings round-trip | open `/settings` in a browser, save | green "Saved." flash + new `updated_at` | + +After Travis's 2a + 2b infra steps: + +| Check | Command | Expect | +|---|---|---| +| DNS | `dig +short brain.herbylab.dev` | `10.0.11.20` | +| TLS | `curl -fsS https://brain.herbylab.dev/dashboard \| head -1` | dashboard HTML | +| Cert | `openssl s_client -connect brain.herbylab.dev:443 -servername brain.herbylab.dev <<<''` | Cloudflare-issued cert | + +--- + +## 4. Follow-ups (not built this round) + +- **SSO via Authentik.** Add the existing forward-auth middleware to the + Traefik router once we want this off-LAN. Authentik lives at + 10.0.21.207:9000 (same host as this container — different port). +- **CSRF.** Settings save is an HTMX POST with no token; trivial to + forge from a same-origin browser. Acceptable while LAN-restricted. +- **Image registry.** Currently build-on-host. Push to Gitea's container + registry (`gitea.plantbasedsoutherner.com/petal-power/second-brain-web`) + when there's a second box that needs to pull the image. +- **Dev-side extraction systemd timer.** Travis is running extraction + manually for now; the timer/cron path is deferred. diff --git a/deploy/web/compose.yml b/deploy/web/compose.yml new file mode 100644 index 0000000..ffb1a5f --- /dev/null +++ b/deploy/web/compose.yml @@ -0,0 +1,41 @@ +# second-brain web — compose file for herbys-dev (10.0.21.207). +# +# House style: file is named `compose.yml`, no bare `docker run`. +# Bring up with: docker compose up -d --build +# Tear down with: docker compose down +# +# The Traefik VM lives on a different host (10.0.11.20) and uses the +# file provider, NOT docker label discovery. We therefore publish the +# uvicorn port back to a fixed host:port that Traefik's static service +# definition can point at. There are deliberately NO traefik.* labels +# in this file — they'd be silently ignored. +# +# The DB connection is the **containerised** one: `homelab-postgres:5432` +# on the homelab bridge network. Do NOT switch this to 127.0.0.1:5433 +# from the container — that loops back inside the container, not the host. + +services: + second-brain-web: + build: + context: ../.. + dockerfile: deploy/web/Dockerfile + image: second-brain-web:latest + container_name: second-brain-web + restart: unless-stopped + env_file: + - .env + environment: + SECOND_BRAIN_CONFIG: /app/config/settings.toml + volumes: + - ../../config/settings.toml:/app/config/settings.toml:ro + networks: + - homelab + # Bind to the herbys-dev LAN IP only so this isn't accidentally + # exposed on other interfaces. Traefik (10.0.11.20) reaches the + # service at http://10.0.21.207:8080 via the LAN. + ports: + - "10.0.21.207:8080:8000" + +networks: + homelab: + external: true