deploy/web: containerized FastAPI UI on the homelab docker network
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.
This commit is contained in:
parent
f9feb0b393
commit
597c83649c
20
deploy/web/.env.example
Normal file
20
deploy/web/.env.example
Normal file
@ -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
|
||||||
2
deploy/web/.gitignore
vendored
Normal file
2
deploy/web/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Real .env carries the lovebug Postgres password. Never commit.
|
||||||
|
.env
|
||||||
63
deploy/web/Dockerfile
Normal file
63
deploy/web/Dockerfile
Normal file
@ -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=*"]
|
||||||
159
deploy/web/README.md
Normal file
159
deploy/web/README.md
Normal file
@ -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 @<knot-host> # 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` | `<!DOCTYPE html>` 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.
|
||||||
41
deploy/web/compose.yml
Normal file
41
deploy/web/compose.yml
Normal file
@ -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
|
||||||
Loading…
Reference in New Issue
Block a user