# Quadlet unit: open-webui (rootless, boot-persistent). # # Install path on dankclaw: ~/.config/containers/systemd/open-webui.container # Unit name: open-webui.service (container name: open-webui) # # Chat UI for the LLM fleet. It runs on the CPU image (dankclaw is CPU-only, so # the :cuda image would waste a large pull for a GPU it cannot use) and points # its OpenAI connection at the live llama.cpp backend on this host via the # host LAN IP (192.168.0.143:7770, model frank-default-large). It publishes 3005 # (3000 is Gitea's HTTP port). Data persists under # /home/dank/Containers/open-webui/data. See serverAppConfigs-docs/Dankclaw/OpenWebUI.md. # # Network history (2026-08-22): this unit used Network=slirp4netns. When the # slirp4netns helper process died, the container kept running but lost its tap # interface: a zombie that accepted TCP and reset every request, and chat # served empty responses for ~2 days with no error anywhere. It now runs on a # pinned bridge network (open-webui.network), which has no per-container # helper to die. The LLM is reached via the host LAN IP (192.168.0.143): # the bridge gateway lives in the rootless netns, not the host netns where # llama-server runs. The healthcheck probes /health (app liveness) and # requires a non-loopback interface (network liveness). [Unit] Description=open-webui chat UI container After=network.target [Container] ContainerName=open-webui Network=open-webui.network Image=ghcr.io/open-webui/open-webui:main PublishPort=3005:8080 Environment=OPENAI_API_BASE_URL=http://192.168.0.143:7770 Environment=ANONYMIZED_TELEMETRY=False Environment=DO_NOT_TRACK=True # SSO via authentik (OIDC authorization-code flow, 2026-09-24). The OAuth # client (application slug "open-webui") lives in authentik; the client # SECRET stays off-repo in the git-ignored host env file (mode 600). The # container reaches the IdP over the public URL: Caddy publishes 0.0.0.0:443 # and Pi-hole resolves auth.franksdank.cloud to the host LAN IP, so the # server-side code exchange works from this bridge. Roles come from the # "Open WebUI roles" scope mapping (entitlements "Open WebUI Users" / # "Open WebUI Admins"); OAUTH_MERGE_ACCOUNTS_BY_EMAIL folds SSO logins into # the existing local account (frank8465@protonmail.com). The OAuth vars are # read directly from the environment at runtime (the per-key config DB holds # only Admin-Panel overrides, so nothing is seeded on the live data dir). # NOTE: the OAUTH_SCOPES value MUST stay quoted — systemd splits unquoted # Environment= values on whitespace (unquoted, only "openid" survived). EnvironmentFile=/home/dank/Containers/open-webui/env Environment=OAUTH_CLIENT_ID=KmtzKc0bjWjaIFeM0puYudI1CLXR2p0jnPCSf4N0 Environment=OAUTH_PROVIDER_NAME=authentik Environment=OPENID_PROVIDER_URL=https://auth.franksdank.cloud/application/o/open-webui/.well-known/openid-configuration Environment=OPENID_REDIRECT_URI=https://ai.franksdank.cloud/oauth/oidc/callback Environment=WEBUI_URL=https://ai.franksdank.cloud Environment=ENABLE_OAUTH_SIGNUP=true Environment=OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true Environment="OAUTH_SCOPES=openid email profile roles" Environment=ENABLE_OAUTH_ROLE_MANAGEMENT=true # SSO-only (2026-09-24): the OIDC round-trip is verified, so the local # username/password path is retired. ENABLE_LOGIN_FORM hides the form; # ENABLE_PASSWORD_AUTH refuses password logins entirely, so the only way in # is the authentik OIDC flow (which folds into the local account by email). # The per-key config DB override (ui.enable_login_form) is flipped to false # in the same change — the DB wins over env, so both must agree. Environment=ENABLE_LOGIN_FORM=false Environment=ENABLE_PASSWORD_AUTH=false # Web search -> SearXNG, reached over the host-published port (NOT the # search.franksdank.cloud vhost): the Anubis->Authentik guard chain is a # browser mechanism and a plain HTTP client cannot solve it. The internal # port bypasses Caddy entirely. These env vars SEED the per-key config DB on # first run (fill-missing only); once the keys exist in /app/backend/data the # DB is authoritative, so this block documents the intent and makes a fresh # data dir come up wired. SearXNG's limiter exempts this source (the bridge # egress 10.89.x is in pass_ip), so the 4-req/IP/hour JSON cap never applies. Environment=ENABLE_WEB_SEARCH=True Environment=WEB_SEARCH_ENGINE=searxng Environment=SEARXNG_QUERY_URL=http://192.168.0.143:8081/search Environment=SEARXNG_LANGUAGE=all Environment=WEB_SEARCH_RESULT_COUNT=6 Volume=/home/dank/Containers/open-webui/data:/app/backend/data # App liveness (/health) AND network liveness (a non-loopback interface must # exist; a dead tap leaves only "lo" behind). HealthCmd=CMD-SHELL python -c 'import os,urllib.request,sys; urllib.request.urlopen("http://127.0.0.1:8080/health",timeout=5).read(); sys.exit(0 if any(i!="lo" for i in os.listdir("/sys/class/net")) else 1)' HealthInterval=30s HealthTimeout=10s HealthRetries=3 HealthStartPeriod=300s [Service] TimeoutStartSec=900 Restart=always [Install] WantedBy=default.target