# 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. # That forces one setting: the frontend auto-detects the API as # `:5055`, so API_URL in scripts/open-notebook.env must name # the remapped port explicitly, or the UI loads and every call fails. Both # ports are published on all interfaces because the consumer is a browser on # the LAN — the fleet's browser-facing services (open-webui, comfyui, gitea) # bind the same way, while loopback-only is reserved for services whose user # path is the guard chain. This service has NO vhost and no Caddy route: it is # internal-only until it is guarded, and its own password # (OPEN_NOTEBOOK_PASSWORD) is what stands in front of it. That password is not # decoration: with it unset the API answers everyone on the LAN, 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=0.0.0.0: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