Skip to content

openwebui-litellm-multideploy

Modules

A single NixOS module that runs Open WebUI in front of an OpenAI-compatible backend (e.g. LiteLLM), behind an nginx TLS vhost — and lets you pick one of three interchangeable runtimes from the same option set:

deploymentMethod Runtime
docker (default) OCI container via virtualisation.oci-containers
nixos-container declarative NixOS container on a private link
systemd native systemd service (uses pkgs.open-webui)

The host-side user/group, the dataDir layout (data/, cache/, static/, vector_db/), and the nginx vhost are shared across all three. Only the runtime wrapper and the env-var plumbing differ.

Why it exists

Open WebUI is easy to run once and annoying to run portably: the container and the native package want their state, ports, and reverse proxy wired up slightly differently, and the differences are exactly where things break silently. This module encodes one config surface and three vetted backends so you can switch runtime without re-deriving the vhost, the persistence layout, or the security sandbox — and it bakes in the traps below so you don't rediscover them.

The traps this encodes

1. Do not re-set the Host header in nginx

The / location intentionally omits proxy_set_header Host. recommendedProxySettings already forwards Host; adding a second one sends a duplicate Host header, and uvicorn (Open WebUI's server) rejects the request with 400 "Invalid HTTP request received". The long timeouts, proxy_buffering off, and client_max_body_size 100M in that block are deliberate — they're for streamed chat responses and file uploads.

2. Under docker / nixos-container, your RAG vector store is not persisted

CHROMA_DATA_PATH and STATIC_DIR point at dataDir/vector_db and dataDir/static, but the docker and nixos-container methods bind-mount only data/ and cache/. So under those two methods the ChromaDB vector store and static assets actually live inside the container filesystem and are lost on recreate. Only the systemd method marks the whole tree writable and persists everything. If you use docker/nixos-container and rely on RAG, add mounts for vector_db/ and static/.

3. Eight of the typed options are decorative

embeddingEngine, embeddingModel, enableWebSearch, webSearchEngine, enableImageGeneration, enableAudioTranscription, and observability.{serviceName,enableMetrics} are declared but never referenced in the config — setting them does nothing. They're kept for documentation/compat. Drive the real features through extraEnvironment, using Open WebUI's own env vars (e.g. ENABLE_RAG_WEB_SEARCH, RAG_WEB_SEARCH_ENGINE, ENABLE_OLLAMA_API, AUDIO_STT_*, OAuth vars, …). extraEnvironment merges last, so it also overrides any default this module sets.

The security-relevant enableSignup and defaultUserRole options are wired (to ENABLE_SIGNUP / DEFAULT_USER_ROLE): set enableSignup = false to actually close self-service registration. You can still override them via extraEnvironment.

The one observability field that is wired is observability.otlpEndpoint (only when observability.enable = true).

Usage

{
  imports = [ ./openwebui-litellm-multideploy ];

  services.openwebuiMulti = {
    enable = true;
    deploymentMethod = "systemd";      # or "docker" / "nixos-container"

    domain = "chat.example.com";
    acmeHost = "example.com";           # required in practice (useACMEHost)

    # OpenAI-compatible backend (LiteLLM, vLLM router, etc.) — keep the /v1
    backendHost = "http://127.0.0.1:4000/v1";
    # backendApiKey left null -> a placeholder key is sent (fine for a keyless
    # local gateway). Never hardcode a real secret in a public config; inject it
    # through extraEnvironment / an EnvironmentFile instead.

    # Real feature config goes here, NOT through the decorative toggles:
    extraEnvironment = {
      ENABLE_SIGNUP = "false";
      DEFAULT_USER_ROLE = "pending";
      ENABLE_RAG_WEB_SEARCH = "true";
      RAG_WEB_SEARCH_ENGINE = "searxng";
      SEARXNG_QUERY_URL = "http://127.0.0.1:8888/search?q=<query>";
    };
  };
}

For the docker method the default image is already pinned to a released version by digest; to change it, keep the digest form:

services.openwebuiMulti = {
  deploymentMethod = "docker";
  image = "ghcr.io/open-webui/open-webui:v0.10.2@sha256:9fcea9c6e32ab60b0498f3986c6cdf651ddbe61db48d2213a3d28048ddd673d4";
  # imageFile = ./open-webui.tar.gz;  # or load a locally built image
};

Key options

Option Default Purpose
deploymentMethod "docker" docker | nixos-container | systemd
domain null nginx vhost domain (required)
acmeHost null ACME cert host (useACMEHost; required in practice)
port 8080 listen + proxy port
dataDir /var/lib/openwebui host state dir
uid / gid 1316 service user/group ids
package pkgs.open-webui package for systemd / nixos-container
image / imageFile ghcr v0.10.2@sha256:… / null OCI image for docker (digest-pinned)
containerBackend "docker" docker | podman
containerNetwork.{host,local}Address 192.168.201.{1,2} nixos-container private link
containerNameservers [1.1.1.1 8.8.8.8] resolvers inside nixos-container
backendHost null OPENAI_API_BASE_URL (include /v1)
backendApiKey null OPENAI_API_KEY (placeholder if null)
enableSignup true ENABLE_SIGNUP; set false to close registration
defaultUserRole "pending" DEFAULT_USER_ROLE (admin | user | pending)
observability.{enable,otlpEndpoint} off / null the only wired OTEL export
extraEnvironment {} the real feature escape hatch (merged last)

Notes / caveats

  • docker networking: the port binds to 127.0.0.1 only (opened on the docker0 firewall interface), and --add-host=host.docker.internal:host-gateway lets the container reach a host-local backend.
  • nixos-container networking: runs on a private 192.168.201.0/24 link with its own resolvers, so it doesn't inherit the host's DNS.
  • Security sandbox: the systemd/nixos-container methods run under a hardened unit (ProtectSystem=strict, NoNewPrivileges, a SystemCallFilter allowlist, restricted address families, etc.). ffmpeg is on PATH for audio/video handling.
  • Secrets: don't put real API keys in backendApiKey in a checked-in config. Use an *_API_KEY_FILE env var in extraEnvironment pointing at a runtime secret, or a systemd EnvironmentFile.
  • Image pinning (docker method): the default image is pinned by digest to a released version (v0.10.2), so a container (re)create can never silently run different code. The trade-off: you must bump the tag and digest yourself to update. Do NOT switch it to a mutable tag (:main, :latest) — that re-pulls whatever upstream currently publishes (force-push, tag hijack, compromised build) with no change to your Nix config. A locally built imageFile also works.
  • Requires services.nginx enabled with recommendedProxySettings = true (the Host-header behaviour above depends on it) and ACME configured for acmeHost. ```

Source

modules/openwebui-litellm-multideploy/default.nix

```nix

openwebui-litellm-multideploy

One option set that fronts Open WebUI (over an OpenAI-compatible backend such

as LiteLLM) behind nginx TLS, in one of three interchangeable ways:

deploymentMethod = "docker" OCI container via virtualisation.oci-containers

| "nixos-container" declarative NixOS container, private network

| "systemd" native systemd service (needs pkgs.open-webui)

The host-side user/group, data directory layout, and nginx vhost are shared

across all three; only the runtime wrapper differs.

Traps this module bakes in (see README):

- the nginx "/" location deliberately does NOT re-set the Host header

- under docker / nixos-container the vector store + static dir live INSIDE

the container and are lost on recreate unless you add mounts

- ~10 of the typed feature toggles are decorative; real features go through

extraEnvironment

Import it and set config.services.openwebuiMulti.* .

{ config, lib, pkgs, ... }: with lib; let cfg = config.services.openwebuiMulti;

openwebuiPackage = cfg.package;

commonEnvironment = { PORT = toString cfg.port; WEBUI_URL = "https://${cfg.domain}";

OPENAI_API_BASE_URL = cfg.backendHost;
OPENAI_API_KEY = if cfg.backendApiKey != null then cfg.backendApiKey else "dummy-key-for-local";

CORS_ALLOW_ORIGIN = "https://${cfg.domain}";

ANONYMIZED_TELEMETRY = "false";
CHROMA_ANONYMIZED_TELEMETRY = "false";
STATIC_DIR = "${cfg.dataDir}/static";

CHROMA_DATA_PATH = "${cfg.dataDir}/vector_db";
CHROMA_PERSIST_DIRECTORY = "${cfg.dataDir}/vector_db";

# Security-relevant account policy: wired so the typed toggles actually take
# effect. Defaults match Open WebUI's own defaults, so this changes nothing
# unless you set the options. extraEnvironment still merges last and wins.
ENABLE_SIGNUP = boolToString cfg.enableSignup;
DEFAULT_USER_ROLE = cfg.defaultUserRole;

} // (optionalAttrs (cfg.observability.enable && cfg.observability.otlpEndpoint != null) { OTEL_EXPORTER_OTLP_ENDPOINT = cfg.observability.otlpEndpoint; }) // cfg.extraEnvironment;

sharedServiceConfig = { Type = "simple"; ExecStart = "${openwebuiPackage}/bin/open-webui serve --host 0.0.0.0 --port ${toString cfg.port}"; Restart = "always"; RestartSec = "10s";

User = "openwebui";
Group = "openwebui";

NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [
  "AF_INET"
  "AF_INET6"
  "AF_UNIX"
];
RestrictNamespaces = true;
LockPersonality = true;
RestrictRealtime = true;
SystemCallFilter = [
  "@system-service"
  "~@privileged"
  "@resources"
];
UMask = "0022";

}; in { options.services.openwebuiMulti = { enable = mkEnableOption "Open WebUI service (docker / nixos-container / systemd)";

deploymentMethod = mkOption {
  type = types.enum [
    "docker"
    "nixos-container"
    "systemd"
  ];
  default = "docker";
  description = ''
    Deployment method for Open WebUI:
    - docker: Run as an OCI container (default)
    - nixos-container: Run in a declarative NixOS container
    - systemd: Run as a native systemd service (requires the open-webui package)
  '';
};

package = mkOption {
  type = types.package;
  default = pkgs.open-webui or (throw "services.openwebuiMulti.package: pkgs.open-webui not available; set services.openwebuiMulti.package explicitly");
  defaultText = literalExpression "pkgs.open-webui";
  description = ''
    Open WebUI package, used by the systemd and nixos-container deployment
    methods (the docker method uses the OCI image instead). Only evaluated
    when one of those methods is selected.
  '';
};

domain = mkOption {
  type = types.nullOr types.str;
  default = null;
  example = "chat.example.com";
  description = "Public domain name for the nginx vhost (required).";
};

acmeHost = mkOption {
  type = types.nullOr types.str;
  default = null;
  example = "example.com";
  description = ''
    ACME certificate host for the nginx vhost (useACMEHost). Required in
    practice: nginx fails to evaluate useACMEHost when this is null.
  '';
};

port = mkOption {
  type = types.port;
  default = 8080;
  description = "Port Open WebUI listens on (and nginx proxies to).";
};

dataDir = mkOption {
  type = types.str;
  default = "/var/lib/openwebui";
  description = "Host directory holding Open WebUI state (data/cache/static/vector_db).";
};

uid = mkOption {
  type = types.int;
  default = 3200;
  description = "UID for the openwebui service user.";
};

gid = mkOption {
  type = types.int;
  default = 3200;
  description = "GID for the openwebui service group.";
};

# --- docker ---------------------------------------------------------------

containerBackend = mkOption {
  type = types.enum [
    "docker"
    "podman"
  ];
  default = "docker";
  description = "OCI backend used when deploymentMethod is 'docker'.";
};

image = mkOption {
  type = types.str;
  default = "ghcr.io/open-webui/open-webui:v0.10.2@sha256:9fcea9c6e32ab60b0498f3986c6cdf651ddbe61db48d2213a3d28048ddd673d4";
  description = ''
    OCI image reference used when deploymentMethod is 'docker'. The default
    is pinned to a released version by digest so a redeploy can never
    silently pull different code (a mutable tag like `:main` or `:latest`
    would). When you bump the version, update the digest with it — or point
    at a locally built image loaded via imageFile.
  '';
};

imageFile = mkOption {
  type = types.nullOr types.path;
  default = null;
  description = ''
    Optional image tarball (e.g. from dockerTools.pullImage /
    buildLayeredImage) loaded before starting the container. When null the
    backend pulls `image` from its registry.
  '';
};

# --- nixos-container ------------------------------------------------------

containerNetwork = mkOption {
  type = types.submodule {
    options = {
      hostAddress = mkOption {
        type = types.str;
        default = "192.168.201.1";
        description = "Host side of the private container link (nixos-container).";
      };
      localAddress = mkOption {
        type = types.str;
        default = "192.168.201.2";
        description = "Container side of the private container link (nixos-container).";
      };
    };
  };
  default = { };
  description = "Private-network addresses for the nixos-container deployment.";
};

containerNameservers = mkOption {
  type = types.listOf types.str;
  default = [
    "1.1.1.1"
    "8.8.8.8"
  ];
  description = "Resolvers configured inside the nixos-container.";
};

# --- backend (OpenAI-compatible, e.g. LiteLLM) ----------------------------

backendHost = mkOption {
  type = types.nullOr types.str;
  default = null;
  example = "http://127.0.0.1:4000/v1";
  description = ''
    OpenAI-compatible backend base URL (OPENAI_API_BASE_URL). Include the
    `/v1` suffix. Point this at your LiteLLM / gateway endpoint.
  '';
};

backendApiKey = mkOption {
  type = types.nullOr types.str;
  default = null;
  description = ''
    API key for the backend (OPENAI_API_KEY). When null, a literal
    placeholder is sent so a keyless local gateway still authenticates.
    Do not hardcode a real secret here in a public config; inject it via
    extraEnvironment with an *_FILE variable or an EnvironmentFile instead.
  '';
};

# --- decorative typed toggles (declared, NOT wired — see README) ----------
# Kept for API compatibility / documentation. Setting them does nothing;
# drive the real features through extraEnvironment.

embeddingEngine = mkOption {
  type = types.enum [
    "openai"
    "ollama"
    "sentence-transformers"
  ];
  default = "openai";
  description = "Decorative. Not wired into config; use extraEnvironment.";
};

embeddingModel = mkOption {
  type = types.str;
  default = "text-embedding-3-small";
  description = "Decorative. Not wired into config; use extraEnvironment.";
};

enableSignup = mkOption {
  type = types.bool;
  default = true;
  description = ''
    Whether self-service account signup is allowed (ENABLE_SIGNUP). Set to
    false to close registration. Wired into the runtime env; extraEnvironment
    (ENABLE_SIGNUP) still overrides it.
  '';
};

defaultUserRole = mkOption {
  type = types.enum [
    "admin"
    "user"
    "pending"
  ];
  default = "pending";
  description = ''
    Role assigned to newly registered users (DEFAULT_USER_ROLE). "pending"
    requires admin approval before access. Wired into the runtime env;
    extraEnvironment (DEFAULT_USER_ROLE) still overrides it.
  '';
};

enableWebSearch = mkOption {
  type = types.bool;
  default = false;
  description = "Decorative. Not wired into config; use extraEnvironment.";
};

webSearchEngine = mkOption {
  type = types.str;
  default = "searxng";
  description = "Decorative. Not wired into config; use extraEnvironment.";
};

enableImageGeneration = mkOption {
  type = types.bool;
  default = false;
  description = "Decorative. Not wired into config; use extraEnvironment.";
};

enableAudioTranscription = mkOption {
  type = types.bool;
  default = false;
  description = "Decorative. Not wired into config; use extraEnvironment.";
};

observability = {
  enable = mkEnableOption "OpenTelemetry export (only otlpEndpoint is actually wired)";

  otlpEndpoint = mkOption {
    type = types.nullOr types.str;
    default = null;
    example = "http://localhost:4318";
    description = "OTLP collector endpoint (OTEL_EXPORTER_OTLP_ENDPOINT). The one wired observability field.";
  };

  serviceName = mkOption {
    type = types.str;
    default = "openwebui";
    description = "Decorative. Not wired into config.";
  };

  enableMetrics = mkOption {
    type = types.bool;
    default = false;
    description = "Decorative. Not wired into config.";
  };
};

extraEnvironment = mkOption {
  type = types.attrsOf types.str;
  default = { };
  description = ''
    Extra environment variables, merged LAST into the container/service env
    so they also override defaults. This is the real escape hatch: web
    search, image generation, OAuth, Ollama, speech-to-text, signup policy,
    etc. are all configured here via Open WebUI's own env vars.
  '';
};

};

config = mkIf cfg.enable (mkMerge [ { assertions = [ { assertion = cfg.domain != null; message = "services.openwebuiMulti: domain must be set"; } { assertion = cfg.deploymentMethod == "systemd" -> (cfg.package != null); message = "services.openwebuiMulti: systemd deployment requires the open-webui package"; } ];

  users.users.openwebui = {
    uid = cfg.uid;
    isSystemUser = true;
    group = "openwebui";
    description = "Open WebUI service user";
  };

  users.groups.openwebui.gid = cfg.gid;

  systemd.tmpfiles.rules = [
    "d ${cfg.dataDir} 0700 ${toString cfg.uid} ${toString cfg.gid}"
    "d ${cfg.dataDir}/data 0700 ${toString cfg.uid} ${toString cfg.gid}"
    "d ${cfg.dataDir}/cache 0700 ${toString cfg.uid} ${toString cfg.gid}"
    "d ${cfg.dataDir}/static 0700 ${toString cfg.uid} ${toString cfg.gid}"
    "d ${cfg.dataDir}/vector_db 0700 ${toString cfg.uid} ${toString cfg.gid}"
  ];

  services.nginx.virtualHosts.${cfg.domain} = {
    forceSSL = true;
    useACMEHost = cfg.acmeHost;
    locations."/" = {
      proxyPass = "http://${
        if cfg.deploymentMethod == "nixos-container" then cfg.containerNetwork.localAddress else "127.0.0.1"
      }:${toString cfg.port}";
      proxyWebsockets = true;
      # NOTE: do NOT add `proxy_set_header Host ...` here.
      # recommendedProxySettings already forwards Host; a second Host header
      # is a duplicate that uvicorn rejects with 400 "Invalid HTTP request".
      extraConfig = ''
        proxy_connect_timeout 600;
        proxy_send_timeout 600;
        proxy_read_timeout 600;
        send_timeout 600;

        proxy_buffering off;
        proxy_buffer_size 128k;
        proxy_buffers 4 256k;
        proxy_busy_buffers_size 256k;

        client_max_body_size 100M;
      '';
    };
  };
}

(mkIf (cfg.deploymentMethod == "docker") {
  virtualisation.oci-containers = {
    backend = lib.mkDefault cfg.containerBackend;
    containers.openwebui = {
      inherit (cfg) image imageFile;
      environment = commonEnvironment;
      # WARNING: only data/ and cache/ are mounted. CHROMA_DATA_PATH and
      # STATIC_DIR still point at unmounted host paths, so the vector store
      # and static assets land inside the container and are lost on
      # recreate. Add volumes for them if you rely on RAG persistence.
      volumes = [
        "${cfg.dataDir}/data:/app/backend/data"
        "${cfg.dataDir}/cache:/app/backend/cache"
      ];
      ports = [
        "127.0.0.1:${toString cfg.port}:${toString cfg.port}"
      ];
      extraOptions = [
        # lets the container reach a host-local backend (LiteLLM) as
        # host.docker.internal
        "--add-host=host.docker.internal:host-gateway"
      ];
    };
  };

  networking.firewall.interfaces.docker0.allowedTCPPorts = [ cfg.port ];
})

(mkIf (cfg.deploymentMethod == "nixos-container") {
  containers.openwebui = {
    autoStart = true;
    privateNetwork = true;
    hostAddress = cfg.containerNetwork.hostAddress;
    localAddress = cfg.containerNetwork.localAddress;

    config =
      {
        config,
        pkgs,
        ...
      }:
      {
        system.stateVersion = "24.11";

        networking.firewall.allowedTCPPorts = [ cfg.port ];
        networking.nameservers = cfg.containerNameservers;

        environment.systemPackages = [ pkgs.ffmpeg ];

        systemd.services.openwebui = {
          description = "Open WebUI";
          after = [ "network.target" ];
          wantedBy = [ "multi-user.target" ];

          environment = commonEnvironment // {
            DATA_DIR = "/data";
            CACHE_DIR = "/cache";
          };

          preStart = ''
            mkdir -p /data /cache /static /vector_db
            chown -R openwebui:openwebui /data /cache /static /vector_db
            chmod -R 755 /data /cache /static /vector_db
          '';

          path = with pkgs; [
            ffmpeg
            coreutils
          ];

          serviceConfig = sharedServiceConfig // {
            # Same trap as docker: only /data and /cache are bind-mounted
            # back to the host; /static and /vector_db stay in the container.
            ReadWritePaths = [
              "/data"
              "/cache"
            ];
          };
        };

        users.users.openwebui = {
          uid = cfg.uid;
          isSystemUser = true;
          group = "openwebui";
        };
        users.groups.openwebui.gid = cfg.gid;
      };

    bindMounts = {
      "/data" = {
        hostPath = "${cfg.dataDir}/data";
        isReadOnly = false;
      };
      "/cache" = {
        hostPath = "${cfg.dataDir}/cache";
        isReadOnly = false;
      };
    };
  };
})

(mkIf (cfg.deploymentMethod == "systemd") {
  systemd.services.openwebui = {
    description = "Open WebUI";
    after = [ "network.target" ];
    wantedBy = [ "multi-user.target" ];

    environment = commonEnvironment // {
      DATA_DIR = "${cfg.dataDir}/data";
      CACHE_DIR = "${cfg.dataDir}/cache";
    };

    path = with pkgs; [
      ffmpeg
      coreutils
    ];

    # Only the systemd method persists EVERYTHING: the whole tree is
    # writable, so the vector store and static dir survive.
    serviceConfig = sharedServiceConfig // {
      WorkingDirectory = cfg.dataDir;
      ReadWritePaths = [
        cfg.dataDir
        "${cfg.dataDir}/data"
        "${cfg.dataDir}/cache"
        "${cfg.dataDir}/static"
        "${cfg.dataDir}/vector_db"
        "${cfg.dataDir}/vector_db/chroma.sqlite3"
      ];
      PrivateDevices = true;
    };
  };
})

]); } ```