openwebui setup

This commit is contained in:
nick committed 2026-09-23 21:19:04 +00:00
1 parent 733a40fec0
commit 5c4836564c
2 files changed
+224 -81

No files matched your search

+215 -81
View File
@@ -54,89 +54,173 @@ systemd:
RestartSec=5s RestartSec=5s
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
# ── Open WebUI (disabled) ────────────────────────────────────────────── # Open WebUI, with Qdrant alongside it for document embeddings.
# Commented out until it is ready to launch. To re-enable:
# 1. strip the leading "# " from every line of the block below (this
# restores the original indentation exactly)
# 2. un-double the dollar signs in each interpolation: $$ back to $
# 3. re-add the seven OPENWEBUI_* keys to secrets.tmpl (listed below;
# they are removed while this is off so the generated JSON, which
# data.external parses, stays valid)
# #
# OPENWEBUI_DATABASE_URL -> openwebui_database_url # Prerequisites, all of which must exist before the VM is rebuilt:
# OPENWEBUI_SECRET_KEY -> openwebui_secret_key # - Postgres database (plain; no pgvector extension needed -- embeddings
# OPENWEBUI_S3_ACCESS_KEY -> openwebui_s3_access_key # go to Qdrant, and Postgres only holds users, chats and settings)
# OPENWEBUI_S3_SECRET_KEY -> openwebui_s3_secret_key # - openwebui bucket in Garage, plus a key with r/w on it
# OPENWEBUI_OAUTH_CLIENT_ID -> openwebui_oauth_client_id # - an embedding model in LiteLLM matching RAG_EMBEDDING_MODEL below
# OPENWEBUI_OAUTH_CLIENT_SECRET -> openwebui_oauth_client_secret # - an OIDC client at the provider, with the callback URI registered as
# OPENWEBUI_OIDC_PROVIDER_URL -> openwebui_oidc_provider_url # https://llm.atlashorizon.net/oauth/oidc/callback
# - llm.atlashorizon.net terminating TLS and proxying to this host's
# :3000 (not managed here -- see WEBUI_URL below)
# - the OPENWEBUI_* secrets in Infisical (see secrets.tmpl)
# #
# The doubling in step 2 is not cosmetic: templatefile() substitutes on # After a taint: the vector store is empty and Open WebUI does not say so
# raw text before anything parses it as YAML, so an interpolation left # -- knowledge bases still appear, retrieval just returns nothing. Re-embed
# intact here would still be resolved -- and would still fail the plan on # with POST /api/v1/knowledge/reindex (admin). Note that this covers
# a missing key -- even though it sits inside a comment. # knowledge bases only: files attached directly to a single chat are not
# # Open WebUI. # re-indexed, so anything worth keeping belongs in a knowledge base.
# # - name: qdrant.service
# # Prerequisites, all of which must exist before the VM is rebuilt: enabled: true
# # - Postgres database, with "CREATE EXTENSION vector;" run inside it contents: |
# # - openwebui bucket in Garage, plus a key with r/w on it [Unit]
# # - an embedding model in LiteLLM matching RAG_EMBEDDING_MODEL below Description=Qdrant vector database
# # - an OIDC client at the provider, with the callback URI registered as After=docker.service
# # https://llm.atlashorizon.net/oauth/oidc/callback Requires=docker.service
# # - llm.atlashorizon.net terminating TLS and proxying to this host's [Service]
# # :3000 (not managed here -- see WEBUI_URL below) TimeoutStartSec=0
# # - the OPENWEBUI_* secrets in Infisical (see secrets.tmpl) # Both containers sit on a private docker network and Qdrant publishes
# - name: open-webui.service # no ports: it ships with no authentication, and nothing outside this
# enabled: true # host needs to reach it. Creation is idempotent, hence the "-".
# contents: | ExecStartPre=-/usr/bin/docker network create openwebui
# [Unit] ExecStartPre=/usr/bin/docker pull qdrant/qdrant:v1.19.1
# Description=Open WebUI ExecStartPre=-/usr/bin/docker rm --force qdrant
# After=docker.service litellm.service # Vectors live on the VM disk, so a taint destroys them. That is
# Requires=docker.service # deliberate -- they are derived data, rebuildable from the source
# [Service] # files in Garage (see the reindex note above). The bind mount is what
# TimeoutStartSec=0 # keeps them across container restarts and image pulls, which the
# # Large image, so it is pulled up front: a slow first pull is then not # docker rm above would otherwise wipe every time.
# # mistaken for a hung unit. ExecStart=/usr/bin/docker run --name qdrant \
# ExecStartPre=/usr/bin/docker pull ghcr.io/open-webui/open-webui:v0.11.4 --network openwebui \
# ExecStartPre=-/usr/bin/docker rm --force open-webui -v /var/lib/qdrant:/qdrant/storage \
# # No volume is mounted. Every piece of durable state lives off-box -- qdrant/qdrant:v1.19.1
# # Postgres for app data, pgvector for embeddings, Garage for uploads -- ExecStop=/usr/bin/docker stop qdrant
# # so /app/backend/data holds nothing worth keeping and the container Restart=always
# # (and the whole VM) can be rebuilt at will. What stays local is cache RestartSec=5s
# # only, re-fetched on the next start. [Install]
# # WantedBy=multi-user.target
# # gitea-mcp already owns host :8080, so the UI is published on :3000. # Open Terminal: the sandbox Open WebUI's models get a shell in, for
# ExecStart=/usr/bin/docker run --name open-webui \ # running code, installing packages and working on files.
# -e DATABASE_URL=$${openwebui_database_url} \ #
# -e VECTOR_DB=pgvector \ # Anything a model does lands inside this container, not on the host: no
# -e PGVECTOR_DB_URL=$${openwebui_database_url} \ # docker socket, no host paths, no published port. It is reachable only
# -e STORAGE_PROVIDER=s3 \ # over the private "openwebui" network, so the API key below is defence in
# -e S3_ENDPOINT_URL=http://s3.lan.oswaldtheotter.net:30188 \ # depth rather than the only thing standing in the way.
# -e S3_REGION_NAME=garage \ #
# -e S3_BUCKET_NAME=openwebui \ # Note that it does have outbound network access -- it needs it to install
# -e S3_ACCESS_KEY_ID=$${openwebui_s3_access_key} \ # packages -- so a model can reach the rest of the LAN from in here. That
# -e S3_SECRET_ACCESS_KEY=$${openwebui_s3_secret_key} \ # is the part worth thinking about before pointing an untrusted model at
# -e WEBUI_SECRET_KEY=$${openwebui_secret_key} \ # it; docker's --internal would close it off, but would also break package
# -e ENABLE_OLLAMA_API=false \ # installation, which is most of the point.
# -e OPENAI_API_BASE_URL=http://192.168.7.61:4000/v1 \ - name: open-terminal.service
# -e OPENAI_API_KEY=$${litellm_master_key} \ enabled: true
# -e RAG_EMBEDDING_ENGINE=openai \ contents: |
# -e RAG_OPENAI_API_BASE_URL=http://192.168.7.61:4000/v1 \ [Unit]
# -e RAG_OPENAI_API_KEY=$${litellm_master_key} \ Description=Open Terminal sandbox
# -e RAG_EMBEDDING_MODEL=text-embedding-3-small \ After=docker.service
# -e OAUTH_CLIENT_ID=$${openwebui_oauth_client_id} \ Requires=docker.service
# -e OAUTH_CLIENT_SECRET=$${openwebui_oauth_client_secret} \ [Service]
# -e OPENID_PROVIDER_URL=$${openwebui_oidc_provider_url} \ TimeoutStartSec=0
# -e OAUTH_PROVIDER_NAME=SSO \ ExecStartPre=-/usr/bin/docker network create openwebui
# -e ENABLE_OAUTH_SIGNUP=true \ ExecStartPre=/usr/bin/docker pull ghcr.io/open-webui/open-terminal:0.14.0
# -e WEBUI_URL=https://llm.atlashorizon.net \ ExecStartPre=-/usr/bin/docker rm --force open-terminal
# -p 3000:8080 \ # A named volume, not a bind mount: /home/user belongs to the image's
# ghcr.io/open-webui/open-webui:v0.11.4 # unprivileged user, and a named volume inherits that ownership from
# ExecStop=/usr/bin/docker stop open-webui # the image where a root-owned bind mount would not. It lives under
# Restart=always # /var/lib/docker on the VM disk, so a taint discards the workspace --
# RestartSec=5s # the right lifetime for scratch space.
# [Install] ExecStart=/usr/bin/docker run --name open-terminal \
# WantedBy=multi-user.target --network openwebui \
-v open-terminal:/home/user \
-e OPEN_TERMINAL_API_KEY=${openwebui_terminal_api_key} \
ghcr.io/open-webui/open-terminal:0.14.0
ExecStop=/usr/bin/docker stop open-terminal
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
- name: open-webui.service
enabled: true
contents: |
[Unit]
Description=Open WebUI
# After= orders the start only, it does not wait for Qdrant to be
# serving. Restart=always covers the gap if Qdrant is still coming up.
After=docker.service litellm.service qdrant.service open-terminal.service
Requires=docker.service
[Service]
TimeoutStartSec=0
# Large image, so it is pulled up front: a slow first pull is then not
# mistaken for a hung unit.
ExecStartPre=/usr/bin/docker pull ghcr.io/open-webui/open-webui:v0.11.4
ExecStartPre=-/usr/bin/docker network create openwebui
ExecStartPre=-/usr/bin/docker rm --force open-webui
# No volume is mounted. Every piece of durable state lives off-box --
# Postgres for app data, Garage for uploads -- so /app/backend/data
# holds nothing worth keeping and the container can be recreated at
# will. Qdrant is the one exception, and it is expendable by design.
#
# gitea-mcp already owns host :8080, so the UI is published on :3000.
ExecStart=/usr/bin/docker run --name open-webui \
--network openwebui \
-e DATABASE_URL=${openwebui_database_url} \
-e VECTOR_DB=qdrant \
-e QDRANT_URI=http://qdrant:6333 \
-e QDRANT_ON_DISK=true \
-e STORAGE_PROVIDER=s3 \
-e S3_ENDPOINT_URL=http://s3.lan.oswaldtheotter.net:30188 \
-e S3_REGION_NAME=garage \
-e S3_BUCKET_NAME=openwebui \
-e S3_ACCESS_KEY_ID=${openwebui_s3_access_key} \
-e S3_SECRET_ACCESS_KEY=${openwebui_s3_secret_key} \
-e WEBUI_SECRET_KEY=${openwebui_secret_key} \
-e ENABLE_OLLAMA_API=false \
-e OPENAI_API_BASE_URL=http://192.168.7.61:4000/v1 \
-e OPENAI_API_KEY=${litellm_master_key} \
-e RAG_EMBEDDING_ENGINE=openai \
-e RAG_OPENAI_API_BASE_URL=http://192.168.7.61:4000/v1 \
-e RAG_OPENAI_API_KEY=${litellm_master_key} \
-e RAG_EMBEDDING_MODEL=text-embedding-3-small \
-e OAUTH_CLIENT_ID=${openwebui_oauth_client_id} \
-e OAUTH_CLIENT_SECRET=${openwebui_oauth_client_secret} \
-e OPENID_PROVIDER_URL=${openwebui_oidc_provider_url} \
-e OAUTH_PROVIDER_NAME=AtlasHorizon \
-e ENABLE_OAUTH_SIGNUP=true \
-e OAUTH_UPDATE_PICTURE_ON_LOGIN=true \
-e ENABLE_OAUTH_GROUP_MANAGEMENT=true \
-e OAUTH_PICTURE_CLAIM=avatar \
-e "OAUTH_SCOPES=openid email profile groups avatar" \
-e OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true \
-e WEBUI_URL=https://llm.atlashorizon.net \
-e 'TERMINAL_SERVER_CONNECTIONS=[{"id":"local","name":"Open Terminal","url":"http://open-terminal:8000","key":"${openwebui_terminal_api_key}","auth_type":"bearer","enabled":true}]' \
-p 3000:8080 \
ghcr.io/open-webui/open-webui:v0.11.4
ExecStop=/usr/bin/docker stop open-webui
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
# Rebuilds the vector store after a taint. Runs on every boot, but only
# acts when Qdrant holds no Open WebUI collections -- that is, when the
# local volume went with the VM.
#
# The admin API key it authenticates with lives in Postgres, so it
# survives the rebuild that wipes Qdrant. That is what makes this work.
- name: openwebui-reindex.service
enabled: true
contents: |
[Unit]
Description=Rebuild Open WebUI vectors when Qdrant is empty
After=open-webui.service qdrant.service
Requires=open-webui.service qdrant.service
[Service]
Type=oneshot
TimeoutStartSec=30min
ExecStart=/opt/bin/openwebui-reindex.sh
[Install]
WantedBy=multi-user.target
- name: dozzle-agent.service - name: dozzle-agent.service
enabled: true enabled: true
contents: | contents: |
@@ -158,6 +242,12 @@ systemd:
WantedBy=multi-user.target WantedBy=multi-user.target
storage: storage:
directories:
# Qdrant's vector store. On the VM disk on purpose: a taint is meant to
# discard it. The qdrant image runs as root (ARG USER_ID=0), so a
# root-owned 0700 directory is what it expects.
- path: /var/lib/qdrant
mode: 0700
files: files:
# LiteLLM proxy settings. Models stay in the database # LiteLLM proxy settings. Models stay in the database
# (STORE_MODEL_IN_DB), so this file carries general_settings only -- # (STORE_MODEL_IN_DB), so this file carries general_settings only --
@@ -175,6 +265,50 @@ storage:
# honoured only when the direct peer is the connector, so a crafted # honoured only when the direct peer is the connector, so a crafted
# header from anywhere else cannot forge a client IP. Widen it if the # header from anywhere else cannot forge a client IP. Widen it if the
# tunnel ever moves off that subnet, or the header is ignored again. # tunnel ever moves off that subnet, or the header is ignored again.
# Companion to openwebui-reindex.service above.
#
# Note on quoting: templatefile() substitutes on raw text, so shell
# variables are written as $VAR without braces. A braced shell variable
# anywhere in this script would be read as a template reference and fail
# the plan; only the interpolation below is meant to be substituted.
- path: /opt/bin/openwebui-reindex.sh
mode: 0700
contents:
inline: |
#!/usr/bin/bash
set -euo pipefail
OWUI=http://127.0.0.1:3000
OWUI_KEY='${openwebui_admin_api_key}'
# Qdrant publishes no ports -- it is reachable only on the private
# "openwebui" docker network -- so it is probed from inside the
# open-webui container, which is on that network and ships curl.
qdrant_curl() {
docker exec open-webui curl -fsS "$@"
}
# Wait up to 10 minutes for both services to answer.
ready=0
for i in $(seq 1 120); do
if curl -fsS "$OWUI/health" >/dev/null 2>&1 \
&& qdrant_curl http://qdrant:6333/readyz >/dev/null 2>&1; then
ready=1; break
fi
sleep 5
done
[ "$ready" = 1 ] || { echo "services never became ready"; exit 1; }
# Collections are named "<QDRANT_COLLECTION_PREFIX>_<id>", and the
# prefix defaults to open-webui. Any hit means the volume survived.
if qdrant_curl http://qdrant:6333/collections \
| grep -q '"name":"open-webui'; then
echo "Qdrant already populated; skipping reindex"
exit 0
fi
echo "Qdrant empty; reindexing knowledge bases"
curl -fsS -X POST -H "Authorization: Bearer $OWUI_KEY" \
"$OWUI/api/v1/knowledge/reindex"
- path: /etc/litellm/config.yaml - path: /etc/litellm/config.yaml
mode: 0644 mode: 0644
contents: contents:
+9
View File
@@ -15,6 +15,15 @@
{{- if eq $secret.Key "LITELLM_MASTER_KEY" }}"litellm_master_key": "{{$secret.Value}}",{{ end }} {{- if eq $secret.Key "LITELLM_MASTER_KEY" }}"litellm_master_key": "{{$secret.Value}}",{{ end }}
{{- if eq $secret.Key "LITELLM_SALT_KEY" }}"litellm_salt_key": "{{$secret.Value}}",{{ end }} {{- if eq $secret.Key "LITELLM_SALT_KEY" }}"litellm_salt_key": "{{$secret.Value}}",{{ end }}
{{- if eq $secret.Key "HEADSCALE_AUTHKEY" }}"headscale_authkey": "{{$secret.Value}}",{{ end }} {{- if eq $secret.Key "HEADSCALE_AUTHKEY" }}"headscale_authkey": "{{$secret.Value}}",{{ end }}
{{- if eq $secret.Key "OPENWEBUI_DATABASE_URL" }}"openwebui_database_url": "{{$secret.Value}}",{{ end }}
{{- if eq $secret.Key "OPENWEBUI_SECRET_KEY" }}"openwebui_secret_key": "{{$secret.Value}}",{{ end }}
{{- if eq $secret.Key "OPENWEBUI_S3_ACCESS_KEY" }}"openwebui_s3_access_key": "{{$secret.Value}}",{{ end }}
{{- if eq $secret.Key "OPENWEBUI_S3_SECRET_KEY" }}"openwebui_s3_secret_key": "{{$secret.Value}}",{{ end }}
{{- if eq $secret.Key "OPENWEBUI_OAUTH_CLIENT_ID" }}"openwebui_oauth_client_id": "{{$secret.Value}}",{{ end }}
{{- if eq $secret.Key "OPENWEBUI_OAUTH_CLIENT_SECRET" }}"openwebui_oauth_client_secret": "{{$secret.Value}}",{{ end }}
{{- if eq $secret.Key "OPENWEBUI_OIDC_PROVIDER_URL" }}"openwebui_oidc_provider_url": "{{$secret.Value}}",{{ end }}
{{- if eq $secret.Key "OPENWEBUI_TERMINAL_API_KEY" }}"openwebui_terminal_api_key": "{{$secret.Value}}",{{ end }}
{{- if eq $secret.Key "OPENWEBUI_ADMIN_API_KEY" }}"openwebui_admin_api_key": "{{$secret.Value}}",{{ end }}
{{- if eq $secret.Key "DOZZLE_OIDC_CLIENT_ID" }}"dozzle_oidc_client_id": "{{$secret.Value}}",{{ end }} {{- if eq $secret.Key "DOZZLE_OIDC_CLIENT_ID" }}"dozzle_oidc_client_id": "{{$secret.Value}}",{{ end }}
{{- if eq $secret.Key "DOZZLE_OIDC_CLIENT_SECRET" }}"dozzle_oidc_client_secret": "{{$secret.Value}}",{{ end }} {{- if eq $secret.Key "DOZZLE_OIDC_CLIENT_SECRET" }}"dozzle_oidc_client_secret": "{{$secret.Value}}",{{ end }}
{{- end }} {{- end }}