# Quadlet unit: open-notebook (rootless, boot-persistent). # # Install path on dankclaw: ~/.config/containers/systemd/open-notebook.container # Unit name: open-notebook.service (container name: open-notebook) # # WHAT IT IS. The fleet's podcast generator: notes and sources go in, a # two-voice (up to four) episode MP3 comes out. One image runs THREE processes # under supervisord — the FastAPI API on 5055, the background worker that # writes and speaks the episode, and the Next.js UI on 8502 — so this one unit # owns the whole app, and the frontend refuses to start until the API answers # (the image's own wait-for-api.sh). Runbook: [[Open Notebook]]. # # IMAGE. `lfnovo/open_notebook:v1-latest` pinned by manifest-list digest, # resolved from the registry on 2026-10-18; it is the tag upstream's own # docker-compose.yml pins for the multi-container shape. Do NOT reach for the # `-single` tags: that variant bundles SurrealDB inside the app image, is # deprecated upstream, and would give this host two databases. Pull=never, so # the pin is fetched by the documented one-time step (docs: [[Open Notebook]]), # which the deployer also runs when the image is absent: # # podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212 # # NETWORKS, in this order on purpose. The generated run line keeps file order, # so the DEFAULT ROUTE rides dankclaw_default: that is the network where this # host has measured that a bridge container can reach a host-namespace # listener through the gateway name (the Prometheus scrape of frank-zealot is # the proof — see [[Container Network Namespaces]]), and frank-tts lives there # too, so the TTS credential can be `http://frank-tts:7772/v1`. The private # systemd-open-notebook bridge is second and carries only the database; its # traffic is on-link, so losing the default route costs it nothing. # # PUBLISHED PORTS, and the 5055 collision. Upstream publishes 8502 and 5055, # and 5055 is exactly the port seerr already holds on this host # (`PublishPort=127.0.0.1:5055:5055`; `ss -ltn` on Dankclaw 2026-10-18 shows # 127.0.0.1:5055 taken). So the API leaves the container on host port 5056. # Only 8502 is published on all interfaces, because it is the single upstream # the guard chain dials: notebook.franksdank.cloud → anubis → authentik → # http://:8017 → 192.168.0.143:8502 (dankclaw/Caddyfile), and this Caddy lives # in root's storage, so a published port is the only way it can reach a rootless # quadlet. The API needs no outward face at all — the image's own Next.js layer # rewrites /api/* to the FastAPI beside it, so one origin serves UI and API — # and the fleet's rule is that loopback-only is for services whose user path is # the guard chain, which this became on 2026-10-19; 5056 stays published on # loopback because the deployer's health gate dials it there. That is also why # API_URL in scripts/open-notebook.env names the vhost rather than a port: left # to itself the frontend auto-detects `:5055`, and the UI loads # while every call fails. OPEN_NOTEBOOK_PASSWORD is not made redundant by the # chain: it is what keeps the API honest for everything else on this host, and # with it unset the API answers anyone, including the unauthenticated # episode-audio download. # # VOLUMES. `/app/data` is a named volume: uploads, the LangGraph SQLite # checkpoints and the extraction caches the image keeps under it # (UV_CACHE_DIR, HF_HOME) are app state nobody browses. The ONE exception is # finished audio, which is bind-mounted out of the container at # /home/dank/media/podcasts. That mount is interim and reversible: episode # destination is an open human-only decision (Gitea issue #7, label # `human-only` — agents must not work it), and until it lands, episodes are # ordinary files on the host. DATA_FOLDER is hard-coded `./data` in the app # (WORKDIR /app) and the DB stores audio paths RELATIVE to data/podcasts, so # covering that one subdirectory is enough and removing the line changes # nothing else. The image declares no USER, so the app runs as container root, # which in this host's rootless mapping (0 -> 1000, measured on this host — # see [[Seerr]]) writes those episode files as dank. # # HEALTH. `GET /health` on the API returns {"status":"healthy"} and is in the # password middleware's excluded-path list, so the probe stays honest after the # password is set (both read from api/main.py upstream). The image installs # curl and ffmpeg in the runtime layer, so the probe needs no shell — kept # argument-only for the generator's space-splitting. Note what this probe does # NOT prove: the route returns a static body, so it says the API serves, not # that the database answers. The DB has its own probe in its unit, and the # worker's failure to reach the database shows up as failed jobs, not as an # unhealthy container. [Unit] Description=open-notebook (podcast generator: API, worker, web UI) After=network-online.target open-notebook-surrealdb.service Wants=network-online.target open-notebook-surrealdb.service [Container] ContainerName=open-notebook Image=docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212 Pull=never Network=dankclaw_default Network=open-notebook.network PublishPort=0.0.0.0:8502:8502 PublishPort=127.0.0.1:5056:5055 EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env EnvironmentFile=/home/dank/Containers/open-notebook/env Volume=open-notebook-data:/app/data Volume=/home/dank/media/podcasts:/app/data/podcasts HealthCmd=curl -fsS http://127.0.0.1:5055/health HealthInterval=30s HealthTimeout=10s HealthRetries=3 HealthStartPeriod=60s [Service] Restart=always RestartSec=10 TimeoutStartSec=900 [Install] WantedBy=default.target