The local username/password path is retired (ENABLE_LOGIN_FORM + ENABLE_PASSWORD_AUTH false, ui.enable_login_form DB override false) and qa-e2e-admin is out of the authentik Admins group. Documented in the SSO section and History.
179 lines
12 KiB
Markdown
179 lines
12 KiB
Markdown
---
|
||
type: reference
|
||
description: "OpenWebUI is a self-hosted chat interface for LLMs."
|
||
tags:
|
||
- dankclaw
|
||
- openwebui
|
||
timestamp: 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-small` (host.containers.internal:7771) — see [The LLM backends](#the-llm-backends) |
|
||
| Network | Pinned bridge `systemd-open-webui` (`10.89.3.0/24`, gateway `10.89.3.1`) — see [History](#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-small` | `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/models` is Ollama-shaped.** llama.cpp returns
|
||
`{"models":[{"name":...}]}`; OpenWebUI's `extract_data()` only reads the
|
||
OpenAI `{"data":[{"id":...}]}` shape, so connection discovery silently misses
|
||
the llama.cpp endpoints (frank-large, frank-small). The fix is to set
|
||
`model_ids` in each connection's `api_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 uses `model_ids`
|
||
too 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 (dankserver `192.168.0.235`) but not to its *own* host's non-published
|
||
ports via the host LAN IP (`192.168.0.143:7770/7771` is 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](../LLM/index.md).
|
||
|
||
## 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=json`
|
||
directly — gluetun's published port, NOT the `search.franksdank.cloud` vhost.
|
||
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). The `pass_ip` list is checked
|
||
FIRST and short-circuits the whole botdetection chain, and OpenWebUI's bridge
|
||
egress (`10.89.x`) is in `pass_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` (slug `open-webui`) with the OAuth2 provider `Open WebUI`
|
||
(confidential client, `authorization_code` + `refresh_token`, Strict
|
||
redirect URI `https://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 via `EnvironmentFile=`).
|
||
- **Roles:** the scope mapping `Open WebUI roles` (scope `roles`) reads the
|
||
application entitlements `Open WebUI Users` → `user` and `Open WebUI
|
||
Admins` → `admin`. The entitlements are bound to the groups
|
||
`openwebui_users` / `openwebui_admins`. `akadmin` (frank8465@protonmail.com)
|
||
is in `openwebui_admins`; `qa-e2e-admin` is in `openwebui_users`.
|
||
- **Account merge:** `OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true` folds 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=true` lets
|
||
other `openwebui_users` create accounts on first login.
|
||
- **IdP reachability:** the container is on its own bridge (10.89.3.0/24) and
|
||
cannot reach `authentik-server:9000` by name. It reaches the IdP over the
|
||
public URL `https://auth.franksdank.cloud` — Caddy publishes `0.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=false` hides the form
|
||
and `ENABLE_PASSWORD_AUTH=false` refuses password logins, so the only way in
|
||
is the authentik OIDC flow. The `ui.enable_login_form` config-DB override was
|
||
flipped to `false` in 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: set `ui.enable_login_form` back to `true` in 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:
|
||
|
||
1. **App liveness** — `GET http://127.0.0.1:8080/health` returns 200 (`{"status":true}`).
|
||
2. **Network liveness** — a non-loopback interface exists in `/sys/class/net`. The dead-tap zombie state (History) leaves only `lo` behind, 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`.
|
||
|
||
## Retired instances
|
||
|
||
- The compose `open-webui` service (port 3000) was never run: 3000 is Gitea's HTTP port, so it could not bind.
|
||
- The bare-container `open-webui` on 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 `searxng` on 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-small `:7771`) are reached via `host.containers.internal`, NOT the host LAN IP: the container's netavark bridge cannot hairpin to its own host's non-published ports via `192.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 IP `192.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 only `lo` in 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 missing `frank-medium`. Rebuilt the `model` table to exactly `frank-large`/`frank-medium`/`frank-small` and set `model_ids` on each connection (llama.cpp's Ollama-shaped `/v1/models` is invisible to OpenWebUI's discovery). Pointed the two on-host endpoints at `host.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)](#sso-authentik) section: application/provider/entitlements created in authentik, client secret in the host env file, roles from the `roles` scope claim. E2E coverage in `tests/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=false` in the quadlet and `ui.enable_login_form` set to `false` in the config DB (the DB wins over env, so both were changed together). `qa-e2e-admin` was demoted out of the `authentik Admins` group (it keeps `openwebui_users` / `homepage-admins` for app access).
|
||
|
||
## See Also
|
||
|
||
- [[Dankclaw Stack]]
|
||
- [[Hermes]] — the agent OpenWebUI can talk to
|
||
- [[SearXNG]] — the search engine on this host
|
||
- [LLM knowledge base](../LLM/index.md) — the vLLM and llama.cpp endpoints
|