mirror of
https://github.com/oqyude/nixos.git
synced 2026-10-07 20:47:14 +03:00
Compare commits
1
Commits
5e9641602a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58333d0257 |
@@ -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";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -20,6 +20,7 @@
|
||||
192.168.1.20 kuma.zeroq.su
|
||||
192.168.1.20 navidrome.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 pdf.zeroq.su
|
||||
192.168.1.20 syncthing.zeroq.su
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
{
|
||||
imports = [
|
||||
../containers/3x-ui.nix
|
||||
../containers/open-webui.nix
|
||||
../containers/tape-rotation.nix
|
||||
../pkgs/beets.nix
|
||||
./acme.nix
|
||||
@@ -38,7 +39,6 @@
|
||||
# ./n8n.nix
|
||||
# ./netdata.nix
|
||||
# ./nfs.nix
|
||||
# ./open-webui.nix
|
||||
# ./rsync.nix
|
||||
# ./step-ca.nix
|
||||
# ./stirling-pdf.nix
|
||||
|
||||
@@ -65,6 +65,12 @@ let
|
||||
domain = "tape-rotation.zeroq.su";
|
||||
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";
|
||||
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" = {
|
||||
forceSSL = true;
|
||||
enableACME = true;
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user