Compare commits

..
1 Commits
Author SHA1 Message Date
oqyude 58333d0257 open-webui via podman added 2026-10-07 13:49:40 +03:00
6 changed files with 220 additions and 29 deletions
+183
View File
@@ -0,0 +1,183 @@
{
config,
lib,
pkgs,
xlib,
...
}:
# Open WebUI — self-hosted AI chat UI, deployed here as a UI-client for
# external LLM APIs (OpenAI-compatible: OpenAI, OpenRouter, vLLM, LM Studio,
# GroqCloud, Mistral, etc.). Runs locally without bundled Ollama.
#
# Architecture mirrors modules/containers/{3x-ui,tape-rotation}.nix:
# - one container, one systemd unit + a root.target
# - data on /mnt/services/nodes/<host>/open-webui/data → /app/backend/data
# (see AGENTS.md §Подтверждённые инварианты #2 — guard chain is satisfied
# because mkServiceStorage already bind-mounts /mnt/services on boot)
# - host port bound to 127.0.0.1 only — the only ingress is the nginx
# vhost open.zeroq.su (no firewall exception, no public exposure).
# Same pattern as 3x-ui.nix:30-31 binding the panel to 127.0.0.1:2049.
#
# Secrets come from a single sops-encrypted dotenv file
# (format = "dotenv", key = "" → whole file). The owner creates the
# encrypted file with `sops modules/containers/secrets/open-webui.env`
# after filling the .example template next to it.
#
# Hard requirement (env.py:762 — SystemExit at startup):
# WEBUI_SECRET_KEY must be set when WEBUI_AUTH=true.
# Generate with: head -c 24 /dev/urandom | base64
#
# Reverse-proxy requirements (docs.openwebui.com/reference/https):
# - WEBUI_URL = public HTTPS URL (OAuth callbacks, internal links)
# - CORS_ALLOW_ORIGIN = same public URL (else WebSocket fails silently)
# - proxy_buffering off (else SSE streaming breaks markdown)
# - proxy_read_timeout ≥ 300s (LLM responses can run minutes)
# - WebSocket pass-through (Upgrade / Connection headers)
# All of the above are wired into modules/server/nginx.nix:open.zeroq.su.
let
panel = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}/open-webui";
in
{
virtualisation = {
podman = {
enable = true;
autoPrune = {
enable = true;
flags = [ "--all" ];
};
dockerCompat = true;
};
oci-containers = {
backend = "podman";
containers."open-webui" = {
image = "ghcr.io/open-webui/open-webui:main";
environment = {
TZ = "Europe/Moscow";
# Container-internal port (also the upstream default).
PORT = "8080";
# Required when behind a public HTTPS URL — OAuth callbacks,
# share links and internal redirects resolve against this.
WEBUI_URL = "https://open.zeroq.su";
# Must exactly match WEBUI_URL or WebSocket connections fail
# silently (per upstream HTTPS docs). nginx (127.0.0.1) is the
# only allowed origin, so a single explicit URL is enough.
CORS_ALLOW_ORIGIN = "https://open.zeroq.su";
# Honour X-Forwarded-* headers from the reverse proxy.
FORWARDED_ALLOW_IPS = "127.0.0.1";
# Closed self-hosted: admin creates accounts manually after the
# first boot via WEBUI_ADMIN_* from the sops env file.
WEBUI_AUTH = "True";
ENABLE_SIGNUP = "False";
ENABLE_LOGIN_FORM = "True";
ENABLE_VERSION_UPDATE_CHECK = "False";
# Out of the box Open WebUI phones home to Scarf. The opt-outs
# below preserve the previous behaviour from the stub at
# modules/server/open-webui.nix (still in tree, commented out in
# modules/server/default.nix:41) until that file is removed.
ANONYMIZED_TELEMETRY = "False";
DO_NOT_TRACK = "True";
SCARF_NO_ANALYTICS = "True";
# No bundled providers. Owners wire OPENAI_API_KEY /
# OPENAI_API_BASE_URL / etc. either via the sops env file
# (see sops.secrets."open-webui-env" below) or interactively in
# Admin → Settings → Connections once WEBUI_AUTH=true. Empty
# base URL is intentional: an empty OPENAI_API_BASE_URL
# disables the default /ollama proxy and prevents the container
# from probing localhost:11434 on boot.
OLLAMA_BASE_URL = "";
OPENAI_API_BASE_URL = "";
};
# Mount the decrypted dotenv only when the sops file exists. Until
# the owner creates ./secrets/open-webui.env, the inline environment
# is the only source — and the container will refuse to start with
# WEBUI_SECRET_KEY="" (env.py:762 — SystemExit). The error message is
# the clear signal that the secret needs to be created.
environmentFiles = lib.optional (builtins.pathExists ./secrets/open-webui.env)
"/run/secrets/open-webui-env";
volumes = [
"${panel}/data:/app/backend/data:rw"
];
log-driver = "journald";
# 127.0.0.1 only — the container is not exposed externally.
ports = [ "127.0.0.1:8080:8080/tcp" ];
};
};
};
# Enable container name DNS for all Podman networks (mirrors 3x-ui.nix:120-128).
networking.firewall.interfaces =
let
matchAll = if !config.networking.nftables.enable then "podman+" else "podman*";
in
{
"${matchAll}".allowedUDPPorts = [ 53 ];
};
systemd = {
services = {
"podman-open-webui" = {
serviceConfig.Restart = lib.mkOverride 90 "always";
partOf = [ "podman-compose-open-webui-root.target" ];
wantedBy = [ "podman-compose-open-webui-root.target" ];
};
"podman-update-open-webui" = {
path = [ pkgs.podman ];
serviceConfig = {
Type = "oneshot";
TimeoutSec = 300;
};
script = ''
podman pull ghcr.io/open-webui/open-webui:main
systemctl restart podman-open-webui.service
'';
};
};
# Starts/stops together with the open-webui container.
targets."podman-compose-open-webui-root" = {
unitConfig.Description = "Root target for open-webui.";
wantedBy = [ "multi-user.target" ];
};
# Enable automatic image updates:
# systemd.timers."podman-update-open-webui" = {
# wantedBy = [ "timers.target" ];
# timerConfig = {
# OnCalendar = "weekly";
# Persistent = true;
# };
# };
tmpfiles.rules = [
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root")
(xlib.helpers.mkTmpfile "d" xlib.dirs.services-nodes-folder "0755" "root" "root")
(xlib.helpers.mkTmpfile "d" "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}" "0755"
"root"
"root"
)
(xlib.helpers.mkTmpfile "d" panel "0755" "root" "root")
(xlib.helpers.mkTmpfile "d" "${panel}/data" "0755" "root" "root")
# Relabel panel dir for SELinux so containers can access it.
(xlib.helpers.mkTmpfile "Z" panel "0755" "root" "root")
];
};
# sops secret is declared only when the encrypted file actually exists,
# so the flake still evaluates (and rebuilds apply) on a host that hasn't
# created the secret yet. Once ./secrets/open-webui.env is created and
# encrypted with `sops modules/containers/secrets/open-webui.env`, this
# condition becomes true and the secret is wired in.
#
# Hard requirement (env.py:762 — SystemExit at startup):
# WEBUI_SECRET_KEY must be present in the env file when WEBUI_AUTH=true.
sops.secrets = lib.optionalAttrs (builtins.pathExists ./secrets/open-webui.env) {
"open-webui-env" = {
# key = "" → decrypt the whole file, not a single key.
# format = "dotenv" → the file IS one .env ready for environmentFiles:
# every non-comment KEY=VALUE line lands in the container environment.
# After this module is wired the file is mounted at
# /run/secrets/open-webui-env (sops-nix default for this attr name).
key = "";
format = "dotenv";
sopsFile = ./secrets/open-webui.env;
mode = "0400";
};
};
}
+10
View File
@@ -0,0 +1,10 @@
WEBUI_SECRET_KEY=ENC[AES256_GCM,data:l6USiQmMkMz/zniIebT35HfXxZI8qrhe6Cdl8hpT98c=,iv:Po9bova4dfiykl+ckH4v6DqzSJOgULx7ro3kXMFRvFI=,tag:7jurD14N7QDRHX5ruFDEeQ==,type:str]
WEBUI_ADMIN_EMAIL=ENC[AES256_GCM,data:EZgNXSpbpROz3TZRLaSQTQ==,iv:i98kChemam9nB3iCMwCTRYB69b2eUBy6QCoeZ3AjAP0=,tag:INnB1Zp9VA6M//yyAhRh3A==,type:str]
WEBUI_ADMIN_NAME=ENC[AES256_GCM,data:8l8dJ85p,iv:LltveatNlX4FEGmxhtYLYmviIvLK0xSMuVsk9DRRglw=,tag:OrTlnOLlQe03hoYTkaPotg==,type:str]
WEBUI_ADMIN_PASSWORD=ENC[AES256_GCM,data:PKfZQHiAa96vcGUCGigjXQ==,iv:WLb1mgCV3IJHnHcBvf4yAPiautgXqCC2L2bzt6i0t7U=,tag:nw78tvyUOYmGMunBwvIr+A==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4d3pNVlZEQS85d2R3WUZX\nKy9iOFZ4MjU2UkQwVHdobTlBY3l0MldONWlvCnU5dllobmtLQXlMM28xN0FSTmxD\nNnhmZVRwdnpaZ3NkZDVCWERBckZiQjgKLS0tIFBPeXZMNXRjZ3pBQUlndXB5MTBB\nMVdhSGJvZkE2VzZiZ2VxL0RKTDJ2aDQKsxlibeAoO74411VemXT+8UBG0JdemgHD\nVONIEp/VsbEJDWgDfSGhLaH4KN2hTsCtyhdkCU0FohgWB+xWyJz6MA==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
sops_lastmodified=2026-10-07T09:32:44Z
sops_mac=ENC[AES256_GCM,data:SSHgEEc3u2Zf13q5W4LD7bkrVlQTzLIYiZWXhBiDS6CjC4fUZcJq99OTSTNixzqpxSdnjeRtmzA6d6vGNfxvEOmsE1f4hBNm9ps0RHU4yLfr5vQG9Ff973uDwDU+JMqP3aMU+xpUuPkhW0zRpeST+w0thuPtjzhR2z/A2yPvYzU=,iv:5/cfD51eK0R9cGsr4wZu6CnwEdMjP0CYj3CM7+X4XQg=,tag:1FRcAULHG+XzmRiTMK8aWQ==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.13.3
+1
View File
@@ -20,6 +20,7 @@
192.168.1.20 kuma.zeroq.su 192.168.1.20 kuma.zeroq.su
192.168.1.20 navidrome.zeroq.su 192.168.1.20 navidrome.zeroq.su
192.168.1.20 nextcloud.zeroq.su 192.168.1.20 nextcloud.zeroq.su
192.168.1.20 open.zeroq.su
192.168.1.20 office.zeroq.su 192.168.1.20 office.zeroq.su
192.168.1.20 pdf.zeroq.su 192.168.1.20 pdf.zeroq.su
192.168.1.20 syncthing.zeroq.su 192.168.1.20 syncthing.zeroq.su
+1 -1
View File
@@ -6,6 +6,7 @@
{ {
imports = [ imports = [
../containers/3x-ui.nix ../containers/3x-ui.nix
../containers/open-webui.nix
../containers/tape-rotation.nix ../containers/tape-rotation.nix
../pkgs/beets.nix ../pkgs/beets.nix
./acme.nix ./acme.nix
@@ -38,7 +39,6 @@
# ./n8n.nix # ./n8n.nix
# ./netdata.nix # ./netdata.nix
# ./nfs.nix # ./nfs.nix
# ./open-webui.nix
# ./rsync.nix # ./rsync.nix
# ./step-ca.nix # ./step-ca.nix
# ./stirling-pdf.nix # ./stirling-pdf.nix
+25
View File
@@ -65,6 +65,12 @@ let
domain = "tape-rotation.zeroq.su"; domain = "tape-rotation.zeroq.su";
port = 5174; port = 5174;
} }
# NOTE: open.zeroq.su is intentionally NOT in this `sites` list —
# mkProxy hard-codes ${server} = 192.168.1.20, but the Open WebUI
# container binds to 127.0.0.1:8080 only (loopback, see
# modules/containers/open-webui.nix). The vhost is added directly
# to `virtualHosts` below, alongside x.zeroq.su (3x-ui panel,
# same loopback-only pattern).
{ {
domain = "navidrome.zeroq.su"; domain = "navidrome.zeroq.su";
port = 4533; port = 4533;
@@ -162,6 +168,25 @@ in
}; };
}; };
}; };
# Open WebUI — same loopback-only pattern as x.zeroq.su above.
# The container listens on 127.0.0.1:8080 (modules/containers/open-webui.nix),
# so we proxy_pass to 127.0.0.1, not the LAN IP. The two extra
# directives are required by the upstream HTTPS docs:
# proxy_buffering off for SSE streaming (markdown in chat breaks
# under the default `proxy_buffering on` from recommendedProxySettings),
# and a 300 s read timeout for long LLM completions.
"open.zeroq.su" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://127.0.0.1:8080";
proxyWebsockets = true;
};
extraConfig = ''
proxy_buffering off;
proxy_read_timeout 300s;
'';
};
"zeroq.su" = { "zeroq.su" = {
forceSSL = true; forceSSL = true;
enableACME = true; enableACME = true;
-28
View File
@@ -1,28 +0,0 @@
{
config,
inputs,
lib,
pkgs,
...
}:
{
services = {
open-webui = {
enable = false;
host = "0.0.0.0";
port = 11112;
openFirewall = true;
environment = {
ANONYMIZED_TELEMETRY = "False";
DO_NOT_TRACK = "True";
SCARF_NO_ANALYTICS = "True";
OPENAI_API_BASE_URL = "http://192.168.1.100:1234/v1";
#OLLAMA_API_BASE_URL = "http://127.0.0.1:1234";
WEBUI_AUTH = "True";
ENABLE_SIGNUP = "False";
ENABLE_SIGNUP_PASSWORD_CONFIRMATION = "True";
ENABLE_VERSION_UPDATE_CHECK = "False";
};
};
};
}