Cross-cutting rename of the current llama.cpp endpoint on the RTX 3090
(port 7771) from frank-small to frank-secondary:
- Service trio: scripts/{frank-small.env,frank-small.service,
serve-frank-small.sh,download-frank-small.sh} → frank-secondary.*
- Test: tests/frank_small_unit.bats → tests/frank_secondary_unit.bats
- Runbooks: serverAppConfigs-docs/LLM/frank-small-*.md →
frank-secondary-*.md (9 files); the FreeToken runbook stays
freetoken-frank-small.md (it documents the retired CUDA-era service)
- Identifiers: FRANK_SMALL→FRANK_SECONDARY, llm_small→llm_secondary,
LLM_SMALL_*→LLM_SECONDARY_*, deploy_llm_small→deploy_llm_secondary
- Historical references to the OLD frank-small (FreeToken/CUDA on the
3090, and the Dankserver service that became frank-medium) are
preserved verbatim — they describe what the service was called
at the time.
15 KiB
type, description, tags, timestamp
| type | description | tags | timestamp | ||
|---|---|---|---|---|---|
| reference | OpenWebUI is a self-hosted chat interface for LLMs. |
|
2026-09-21 |
OpenWebUI
OpenWebUI is a self-hosted chat interface for LLMs. It connects to OpenAI-compatible endpoints (vLLM, llama.cpp, Ollama) and adds RAG, plugins, tool calling, and multi-user support.
Host: Dankclaw — Port: 3005 (host) → 8080 (container) — Vhost:
ai.franksdank.cloud routes straight to 192.168.0.143:3005 (unguarded —
OpenWebUI enforces its own auth) — Boot-persistent: Quadlet — Docs:
https://github.com/open-webui/open-webui
Facts
| Item | Value |
|---|---|
| Image | ghcr.io/open-webui/open-webui:main (verified from the live quadlet unit) |
| Upstream latest | 0.11.4 (2026-09-21) — security/access-control advisory; :main is the rolling image, not a stable pin. Imagegen endpoint is POST /api/v1/images/generations (the comfyui engine is still upstream). |
| Port | 3005 (host) → 8080 (container). Port 3000 is Gitea's HTTP, so it is not usable. |
| Data volume | /home/dank/Containers/open-webui/data → /app/backend/data |
| Backends | Three fleet endpoints: frank-large (host.containers.internal:7770), frank-medium (192.168.0.235:7770), frank-secondary (host.containers.internal:7771) — see The LLM backends |
| Network | Pinned bridge systemd-open-webui (10.89.3.0/24, gateway 10.89.3.1) — see History |
| Persistence | Quadlet unit open-webui.service; [Install] WantedBy=default.target |
| Deploy | scripts/deploy-open-webui-quadlet.sh dankclaw |
The LLM backends
OpenWebUI serves the three fleet LLM endpoints. The model picker is built from
the model table in webui.db (one row per model), NOT from live connection
discovery — so the picker shows exactly the models seeded there.
| Picker model | Connection URL | Runtime | Host |
|---|---|---|---|
frank-large |
http://host.containers.internal:7770/v1 |
unsloth llama.cpp (Strix Halo iGPU, 200k ctx) | Dankclaw |
frank-medium |
http://192.168.0.235:7770/v1 |
vllm-radiance TP=2 (FP8, DFlash2, 262k ctx) | Dankserver |
frank-secondary |
http://host.containers.internal:7771/v1 |
llama.cpp (RTX 3090, Gemma4-12B, 262k ctx) | Dankclaw |
Two gotchas make the wiring non-obvious:
- llama.cpp
/v1/modelsis Ollama-shaped. llama.cpp returns{"models":[{"name":...}]}; OpenWebUI'sextract_data()only reads the OpenAI{"data":[{"id":...}]}shape, so connection discovery silently misses the llama.cpp endpoints (frank-large, frank-secondary). The fix is to setmodel_idsin each connection'sapi_config— when non-empty, OpenWebUI builds the model list directly and never fetches/v1/models. frank-medium (vLLM) serves the OpenAI shape and would discover fine, but usesmodel_idstoo for consistency. - The host's own services are reached via
host.containers.internal. The container sits on a netavark bridge; it can hairpin to a different host's LAN IP (dankserver192.168.0.235) but not to its own host's non-published ports via the host LAN IP (192.168.0.143:7770/7771is refused).host.containers.internal(169.254.1.2) resolves to the host on the bridge and reaches both llama.cpp services.
The Ollama connection is disabled (ollama.enable=false).
All three endpoints are bare-metal systemd services managed by this repository. Model swaps, context sizing, and spec tuning happen there. See the LLM knowledge base.
Container introspection (DB and backend python)
-
Working dir for imports:
/app/backend; package nameopen_webui(underscore), NOTopenwebui.os.environ["WEBUI_SECRET_KEY"]MUST be set BEFORE importing anyopen_webuimodule (import-time hard requirement); the secret lives at/app/backend/.webui_secret_key. -
The
sqlite3CLI is NOT in the container — use Python'ssqlite3module (podman exec open-webui python3 -c "import sqlite3; conn = sqlite3.connect('/app/backend/data/webui.db'); …"). For config keys use parameterized LIKE queries (avoids shell quoting). -
Backend python:
podman exec -w /app/backend open-webui python3 -c "…"—Models.get_all_models()is a coroutine (await it); model objects are pydanticModelModelinstances (attribute access, not.get()), with NOstatus/enabledattribute — useis_activefrom the DB. -
API auth for verification:
create_tokenlives inopen_webui.utils.auth(NOTopen_webui.utils.utils);get_verified_userrequires anidclaim (not justsub);SESSION_SECRET = WEBUI_SECRET_KEY. Mint an admin token inside the container:podman exec -i open-webui sh -c 'export WEBUI_SECRET_KEY=$(cat /app/backend/.webui_secret_key); \ python -c "from open_webui.utils.auth import create_token; \ print(create_token({\"id\":\"<UID>\",\"sub\":\"<UID>\",\"email\":\"…\",\"role\":\"admin\"}))"'(admin UID from the
usertable).
Verification battery
GET /api/v1/models/list?limit=100→ returns EXACTLY the intended models. Response key isitems, notdata.POST /openai/chat/completionsper model with a trivial prompt ("Reply with exactly one word: pong") → each returns contentpong. Reasoning models needmax_tokens>=100— the thinking block eats the budget.- A 500 "Server Connection Error" →
podman logs open-webuiforClientConnectorError: a network-reachability problem (host LAN IP vshost.containers.internal), not a model problem.
Durability: the model table + connections are DB-only (not git-managed); a fresh data volume loses them. For reproducibility, add an idempotent first-start seed script or document the setup in the service doc.
Web search
Chat web search is served by SearXNG on this host, wired in the quadlet
(ENABLE_WEB_SEARCH, WEB_SEARCH_ENGINE=searxng, SEARXNG_QUERY_URL).
- Access path: the backend calls
http://192.168.0.143:8081/search?format=jsondirectly — gluetun's published port, NOT thesearch.franksdank.cloudvhost. The Anubis→Authentik guard chain is a browser mechanism (proof-of-work + session flow); a plain HTTP client cannot solve it, and the internal port bypasses Caddy entirely. - Limiter: SearXNG caps non-HTML (JSON) requests at 4/IP/hour (
API_MAX, a hardcoded Python constant — no config knob). Thepass_iplist is checked FIRST and short-circuits the whole botdetection chain, and OpenWebUI's bridge egress (10.89.x) is inpass_ip, so the cap never applies. Verified: an 8-request burst from the container returned 8×200. - Config storage: OpenWebUI keeps web-search settings per-key in its SQLite
config table (
webui.db,web.search.*). The quadlet env vars seed that table on first start (fill-missing only); afterwards the DB is authoritative and Admin-UI edits persist there.
SSO (authentik)
Login is authentik via the OIDC authorization-code flow (wired 2026-09-24).
The vhost stays exempt from the Anubis guard chain: the OAuth dance is
browser-driven and only touches the two exempt vhosts (ai. and auth.).
- authentik objects (created via the admin API, 2026-09-24): application
open-webui(slugopen-webui) with the OAuth2 providerOpen WebUI(confidential client,authorization_code+refresh_token, Strict redirect URIhttps://ai.franksdank.cloud/oauth/oidc/callback, implicit consent flow). The client ID is in the quadlet; the client secret lives in the git-ignored host file/home/dank/Containers/open-webui/env(mode 600, loaded viaEnvironmentFile=). - Roles: the scope mapping
Open WebUI roles(scoperoles) reads the application entitlementsOpen WebUI Users→userandOpen WebUI Admins→admin. The entitlements are bound to the groupsopenwebui_users/openwebui_admins.akadmin(frank8465@protonmail.com) is inopenwebui_admins;qa-e2e-adminis inopenwebui_users. - Account merge:
OAUTH_MERGE_ACCOUNTS_BY_EMAIL=truefolds an SSO login into the existing local account with the same email — the pre-SSO admin (frank8465@protonmail.com) keeps its data.ENABLE_OAUTH_SIGNUP=truelets otheropenwebui_userscreate accounts on first login. - IdP reachability: the container is on its own bridge (10.89.3.0/24) and
cannot reach
authentik-server:9000by name. It reaches the IdP over the public URLhttps://auth.franksdank.cloud— Caddy publishes0.0.0.0:443(netavark DNATs published ports from other containers) and Pi-hole resolves the name to the host LAN IP. The server-side code exchange rides that path. - Config authority: the OAuth env vars in the quadlet seed the per-key config DB on first run (fill-missing only); on the live data dir the Admin Panel is authoritative. To rotate the client secret: create a new token in authentik, update the host env file, and set the new value in the Admin Panel (or the DB), then restart the container.
- SSO-only (2026-09-24): after the OIDC round-trip was verified, the local
username/password path was retired.
ENABLE_LOGIN_FORM=falsehides the form andENABLE_PASSWORD_AUTH=falserefuses password logins, so the only way in is the authentik OIDC flow. Theui.enable_login_formconfig-DB override was flipped tofalsein the same change — the per-key DB wins over env, so both must agree, and the DB value is the one to change on a live data dir. To restore the password form: setui.enable_login_formback totruein the Admin Panel (or DB) and drop the two env vars from the quadlet.
Boot persistence (Quadlet)
The unit lives at dankclaw/open-webui/quadlet/open-webui.container. On the host it installs to ~/.config/containers/systemd/open-webui.container. The generator turns it into open-webui.service. Because the unit sets [Install] WantedBy=default.target and user linger is on, it starts at boot. systemctl --user is-enabled open-webui.service reports generated (the quadlet boot-start state).
Volumes
| Container path | Host path |
|---|---|
/app/backend/data |
/home/dank/Containers/open-webui/data |
The data dir holds the database, RAG indexes, and uploads. The deploy script creates it if missing and preserves it across reruns.
Healthcheck
The quadlet carries a built-in healthcheck (30s interval, 3 retries, 300s start period). It checks two things from inside the container:
- App liveness —
GET http://127.0.0.1:8080/healthreturns 200 ({"status":true}). - Network liveness — a non-loopback interface exists in
/sys/class/net. The dead-tap zombie state (History) leaves onlylobehind, and this is the check that catches it.
podman ps shows the container as unhealthy if either fails. Probe manually at http://dankclaw:3005/health.
Image generation (ComfyUI)
Image generation rides the local ComfyUI integration, not the chat
picker — ComfyUI's models never appear in the chat model picker (that is
chat LLMs only), so ComfyUI needs its own image-generation connection
configured in OpenWebUI. ComfyUI itself runs GPU-enabled on Dankclaw and
serves :8188.
Endpoint: POST /api/images/generations (image 0.11.x: upstream moved the
route to POST /api/v1/images/generations; the comfyui engine is still
upstream) with Authorization: Bearer <token> and body
{"model":"imagegen","prompt":"…","n":1,"size":"1024x1024"}; the response
carries base64 image data or a URL.
Failure modes: connection refused ⇒ OpenWebUI or ComfyUI is down; timeouts on large sizes ⇒ check ComfyUI resources; 401 ⇒ the token is bad.
Retired instances
- The compose
open-webuiservice (port 3000) was never run: 3000 is Gitea's HTTP port, so it could not bind. - The bare-container
open-webuion Dankserver (port 3000) pointed at a dead backend (:9090) and was removed. Its data dir is preserved on Dankserver in case it is needed. - The bare-container
searxngon Dankserver (port 8081) was removed. SearXNG now runs only on Dankclaw behind Caddy.
Gotchas
- The image tracks upstream
main; pin a digest in the quadlet unit if reproducible rollback matters more than tracking. (The old "CPU-only host" framing is stale: this APU runs the gfx1151 ROCm build — see ComfyUI.) - The default admin account is created on first login; secure it immediately.
- If the backend is loading (3–8 minute model load), OpenWebUI shows connection errors until the model is ready.
- The on-host LLM services (frank-large
:7770, frank-secondary:7771) are reached viahost.containers.internal, NOT the host LAN IP: the container's netavark bridge cannot hairpin to its own host's non-published ports via192.168.0.143(connection refused), and the bridge gateway (10.89.3.1) lives in the rootless netns while llama-server runs in the host netns, so it refuses too.host.containers.internal(169.254.1.2) resolves to the host on the bridge and works — verified end-to-end for both endpoints. The Dankserver backend (frank-medium) is a different host, reached via its LAN IP192.168.0.235:7770.
History
-
2026-08-22 — slirp4netns → pinned bridge. The unit ran on
Network=slirp4netns. The slirp4netns helper process died (no OOM, no kill entry, no crash log found), and the container kept running with onlyloin its netns: published port 3005 accepted TCP and reset every request, so chat served empty responses for ~2 days. Nothing logged an error. Moving to a kernel bridge removes the per-container helper entirely, and the healthcheck's interface check makes the zombie state visible if it ever returns. -
2026-09-21 — model picker rebuilt for the 3-model fleet. The picker carried 39 stale rows (37 dead Ollama models + 2
frank-default-*) and was missingfrank-medium. Rebuilt themodeltable to exactlyfrank-large/frank-medium/frank-secondaryand setmodel_idson each connection (llama.cpp's Ollama-shaped/v1/modelsis invisible to OpenWebUI's discovery). Pointed the two on-host endpoints athost.containers.internal— the bridge refuses the host LAN IP for non-published ports. All three verified end-to-end via/openai/chat/completions. -
2026-09-24 — SSO via authentik. OIDC authorization-code flow wired per the SSO (authentik) section: application/provider/entitlements created in authentik, client secret in the host env file, roles from the
rolesscope claim. E2E coverage intests/e2e/specs/18-openwebui-sso.spec.ts. -
2026-09-24 — flipped to SSO-only. The OIDC round-trip verified in a real browser (login view renders no password form, only "Continue with authentik", and the flow lands on a signed-in session), so the local username/password path was retired:
ENABLE_LOGIN_FORM=false+ENABLE_PASSWORD_AUTH=falsein the quadlet andui.enable_login_formset tofalsein the config DB (the DB wins over env, so both were changed together).qa-e2e-adminwas demoted out of theauthentik Adminsgroup (it keepsopenwebui_users/homepage-adminsfor app access).
See Also
- Dankclaw Stack
- Hermes — the agent OpenWebUI can talk to
- SearXNG — the search engine on this host
- LLM knowledge base — the vLLM and llama.cpp endpoints