2026-06-21 Open WebUI Deployment and Auth Cascade

What I set out to do

Started as “what has Open WebUI shipped upstream in the last 6 months, and how do I wire up the bits I’m not using” — custom MCP connectors, native/auto tool calling, image generation, the new desktop app. Turned into a full deployment hardening pass plus a four-bug debugging cascade once I actually tried to chat.

What I actually did

Worked it end to end with Claude Code over a long session. All declarative in nix home-manager; the container runs ENABLE_PERSISTENT_CONFIG=False, so everything has to be env or a SQLite seed — the UI doesn’t persist.

Surveyed upstream (0.9.x). Desktop app, scheduled automations, KB overhaul (directory sync, filesystem tool), native MCP connectors, native function calling, redirect-based SSRF protection.

Desktop app. Installed via Homebrew cask (brew install --cask open-webui) — confirmed no nix-homebrew/Brewfile exists, so it’s a manual Tier-1.5 install. Wrote open-webui-desktop.nix to seed the app’s config.json (server connection + shell settings) via an activation-time jq merge — the app rewrites that file at runtime so a read-only home.file symlink is wrong; mirrors how the web UI’s settings get seeded into SQLite rather than symlinked.

Container features. Added native function calling (DEFAULT_MODEL_PARAMS), image generation, and the Obsidian MCP as a TOOL_SERVER_CONNECTIONS entry (type:"mcp", loopback host.docker.internal:8788/mcp, no auth). Verified the container reaches the loopback bridge directly — no Cloudflare round-trip needed for the same-host case (the connector built earlier today via 2026-06-21 Remote Obsidian MCP Connector is for the phone).

Defaults. Set default chat model to openrouter/deepseek/deepseek-v4-flash. Turned code-interpreter / image / obsidian-tool on by default — which required reading OWUI source to learn there’s no global feature-default: the chat toggles read model.info.meta.defaultFeatureIds (+ capabilities) and meta.toolIds. So I seeded a workspace model row (base_model_id NULL → merges onto the LiteLLM-listed model by id, per utils/models.py) carrying those keys.

Image via OpenRouter. Routed image gen through openrouter/google/gemini-3.1-flash-image with an explicit mode: image_generation LiteLLM model_list entry (rather than trusting the openrouter/* wildcard for the images endpoint).

The debugging cascade (the real story)

  1. Chat → not_found. LiteLLM’s defaultClaudeModel was claude-sonnet-4-20250514, which Anthropic retired 2026-06-15 — six days earlier. Bumped to claude-sonnet-4-6.
  2. DeepSeek → empty tool calls. Not a config bug: OpenRouter’s DeepSeek-V4 provider dropped the stream mid-flight (provider_unavailableMidStreamFallbackError, no fallbacks configured). Triangulated across LiteLLM stderr, the Obsidian bridge log (ListTools 200, no CallTool ever arrived → MCP healthy), and a SigNoz trace.
  3. Image tool → 401. OWUI was calling api.openai.com directly: IMAGES_OPENAI_API_BASE_URL/KEY do not inherit OPENAI_API_BASE_URL/KEY at runtime despite the docs. Set them explicitly to the LiteLLM proxy.
  4. Whole app → 401. WEBUI_SECRET_KEY is unset, and start.sh writes its auto-generated .webui_secret_key to the ephemeral cwd (/app/backend), not the data volume — so every hm switch recreate rotated the JWT key and invalidated all sessions. Resisted the static-secret hack; fixed it cleanly by pointing WEBUI_SECRET_KEY_FILE at the persisted volume. Verified the key hash is now stable across a forced recreate.

What was striking

  • OWUI image failures are invisible in SigNoz traces. The image path uses aiohttp (uninstrumented); chat uses httpx (traced). The 401 lived only in logs — but the OWUI log line carries the trace_id, so you can still jump to the chat trace. Full distributed trace OWUI→LiteLLM→Anthropic is otherwise there, and LiteLLM spans carry the gen_ai.* bodies.
  • The secret-rotation fix is a path, not a secret. OWUI already generates and owns its key; it was just storing it on the ephemeral layer. WEBUI_SECRET_KEY_FILE → data volume is the whole fix.
  • No global feature-default in OWUI — “turn code interpreter on by default” is genuinely per-model (defaultFeatureIds), so it needed a seeded workspace model row.
  • IMAGES_OPENAI_API_BASE_URL silently falls back to api.openai.com, not the configured OpenAI base — a doc/behavior mismatch that 401s with a placeholder key.
  • A retired dated model snapshot 404s the same day it’s pulled; the fix is the undated alias (claude-sonnet-4-6).
  • Same pattern as the morning’s MCP debugging recurred: a flaky upstream provider looks identical to a local misconfiguration — the logs + trace + MCP-server-side log together are what separated “DeepSeek/OpenRouter is down” from “my setup is broken.”

Top tomorrow

  1. Clear the browser’s stale OWUI token (hard refresh / clear site data on localhost:7878) to mint a fresh JWT under the now-stable key, then confirm chat + image both work in-app.
  2. Decide on DeepSeek V4 Flash as default — it’s flaky via OpenRouter under native function calling (empty tool calls). Either pin a more reliable provider route or pick a different default.
  3. Optionally add LiteLLM fallbacks so a mid-stream provider drop fails over instead of returning empty.