seekrit
← all posts

Securing Open WebUI and Ollama deployments

How-to

The usual self-hosted chat stack is Ollama for local models and Open WebUI in front of it. Ollama needs no credential. Open WebUI collects them: an OPENAI_API_KEY for the models Ollama cannot run, a web search key, image generation keys, tool server credentials, and WEBUI_SECRET_KEY, which signs every session token the UI issues. Most of them end up in a .env beside the compose file, and anything set later through the admin panel is written to Open WebUI's database, on the same volume as every chat.

This post covers three steps: get the keys out of the compose file, make Open WebUI hold placeholders for paid APIs, and stop the admin panel from undoing the second step.

Step 1: one credential in the compose file

Import the .env, mint a token, delete the file:

seekrit secrets import .env --app chat --env production
seekrit token create --app chat --env production --name open-webui
rm .env

The Open WebUI image has no entrypoint of its own and starts with bash start.sh, so seekrit-run slots in front of that with an image that adds the binary:

FROM seekritdev/run:latest AS seekrit
FROM ghcr.io/open-webui/open-webui:main
COPY --from=seekrit /seekrit-run /usr/local/bin/seekrit-run
ENTRYPOINT ["seekrit-run", "--"]
CMD ["bash", "start.sh"]
services:
  ollama:
    image: ollama/ollama
    volumes: [ollama:/root/.ollama]

  open-webui:
    build: .
    ports: ["3000:8080"]
    environment:
      SEEKRIT_TOKEN: ${SEEKRIT_TOKEN}
      OLLAMA_BASE_URL: http://ollama:11434
    volumes: [open-webui:/app/backend/data]

volumes:
  ollama:
  open-webui:

At start the container resolves its environment, decrypts it locally, and launches start.sh with WEBUI_SECRET_KEY, OPENAI_API_KEY, and the rest present as environment variables. docker inspect shows one token, bound to one environment, revocable from the dashboard. If you would rather not build an image, seekrit run -- docker compose up -d supplies the ${VAR} values from the child environment instead, at the cost of the values appearing in docker compose config. The Compose post compares the two.

WEBUI_SECRET_KEY deserves a fixed value here rather than the random one Open WebUI generates on first start. A generated one lives in a file on the data volume, and losing it logs every user out. In seekrit it is a secret like any other, rotatable on purpose rather than by accident.

Step 2: placeholders for paid APIs

Open WebUI reaches OpenAI-compatible providers through two variables: OPENAI_API_BASE_URL and OPENAI_API_KEY. Point the first at seekrit-proxy and make the second a placeholder. Neither is a secret any more, so both can live in the compose file:

  open-webui:
    environment:
      SEEKRIT_TOKEN: ${SEEKRIT_TOKEN}
      OLLAMA_BASE_URL: http://ollama:11434
      OPENAI_API_BASE_URL: http://seekrit-proxy:8080/openai/v1
      OPENAI_API_KEY: "{{seekrit:OPENAI_API_KEY}}"

  seekrit-proxy:
    image: seekritdev/proxy
    environment:
      SEEKRIT_TOKEN: ${SEEKRIT_PROXY_TOKEN}
    volumes: ["./seekrit-proxy.toml:/seekrit-proxy.toml"]
    command: ["--listen", "0.0.0.0:8080"]
# seekrit-proxy.toml
[[route]]
prefix = "/openai"
upstream = "https://api.openai.com"
allow = ["OPENAI_API_KEY"]
methods = ["GET", "POST"]
paths = ["/v1/models", "/v1/chat/completions", "/v1/embeddings"]

Open WebUI sends Authorization: Bearer {{seekrit:OPENAI_API_KEY}} to the proxy. The proxy swaps in the real key and forwards to OpenAI, for the two methods and three paths listed and nothing else. The Open WebUI container now holds no provider key at all, in its environment or its database. The same shape covers any provider Open WebUI reaches over an OpenAI-compatible API, which is most of them: OPENAI_API_BASE_URLS and OPENAI_API_KEYS take semicolon-separated lists, so several providers can each get a proxy route.

Once the provider key is out of the container, the token in SEEKRIT_TOKEN should be bound to an environment that does not contain it either. Two tokens, two environments: the Open WebUI one holds WEBUI_SECRET_KEY and the database URL; the proxy's holds the API keys. A compromise of the chat container then yields a signing key and a placeholder, not a provider bill.

Step 3: stop the admin panel from bypassing the proxy

Open WebUI persists settings changed in the admin panel to its database and, by default, prefers those over environment variables on subsequent starts. That is convenient and it is also how the base URL gets pointed back at api.openai.com with a real key pasted in, six months from now, by an admin who did not know the proxy existed. Set:

      ENABLE_PERSISTENT_CONFIG: "false"

With persistent config off, environment variables are authoritative on every start and the connection settings in the UI cannot override them. The trade is that every setting you want has to be in the compose file, which for a deployment you manage is the right place for it anyway.

Ollama

Ollama has no authentication. If OLLAMA_HOST is bound to anything other than loopback or a private compose network, anyone who can reach the port can run inference on your hardware and pull models onto your disk. In the compose file above, Ollama publishes no ports and Open WebUI reaches it by service name. Keep it that way. If you need Ollama reachable from other machines, put it behind something that authenticates, not on 0.0.0.0:11434.

Recap

BeforeAfter
Provider keys in .env and in docker compose configOne token in the compose file, bound to one environment
WEBUI_SECRET_KEY generated into a file on the data volumeA managed secret, rotatable on purpose
OPENAI_API_KEY in the Open WebUI containerA placeholder; the key lives in the proxy sidecar
Admin can paste a key and a direct URL into the UIEnvironment is authoritative
Ollama on 0.0.0.0Ollama on the compose network only

The agent proxy guide has the full route syntax, and the Compose post has the options for getting the token itself into a container without an environment variable.