- Nix 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
HomeFree now composes `nixosModules.homefree` and falls back to `default` for plugins that predate the convention. The two were interchangeable while every plugin existed only to be a plugin — `default` had nothing else it could be. A flake that is also something in its own right breaks that: genai-server's `default` is a standalone NixOS service stack, and registering it composed that stack into the router instead of its ingress module, silently, with every subdomain answering "no service at this host". `default` stays as an alias so this keeps working on a HomeFree that predates the change, and so the lock bump is not a flag day. |
||
| apps | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
homefree-genai
HomeFree apps for genai-server — TLS-fronted, SSO-gated ingress for a GenAI stack that may be running on another machine, plus the surfaces that are better off running here.
Why a plugin and not part of either project
genai-server is a generic service flake; HomeFree is a generic platform.
Neither should know about the other. This repo is the adapter: it speaks
HomeFree's app conventions (homefree.service-config, allocPort,
caddy_gated SSO, homefree.containers, homefree.sso.clients) and points
them at genai-server's endpoints.
The dependency runs one way: this repo takes genai-server as an input, for the definitions it renders. genai-server must never take this one — an input there is something every consumer of the stack has to be able to fetch, and a deployment's hostnames, its Zitadel and its port allocator are nobody else's.
The app
A parent app, genai, plus one sub-app per web surface. The parent holds
only what is shared; each surface owns the decisions that are genuinely
per-surface, because exposing ComfyUI to the WAN is a different act from
exposing the chat UI.
homefree.services = {
genai = {
enable = true; # master switch — gates every surface
backend-host = "logistikon.lan";
};
# Sub-apps default to enabled, SSO on, WAN off. Override as needed:
genai-comfy.enable = false; # don't publish ComfyUI at all
genai-voice.sso = false; # headless client that can't do OIDC
genai-webui.public = true; # reachable from the WAN
};
| sub-app | subdomain | fronts |
|---|---|---|
genai-api |
ai |
the portal — /v1/* and /svc/*, the whole stack |
genai-webui |
webui |
Open WebUI |
genai-comfy |
comfy |
ComfyUI |
genai-search |
search |
SearXNG |
genai-magentic |
magentic |
Magentic-UI |
genai-voice |
voice |
realtime speech-to-speech |
genai-librechat |
librechat |
LibreChat — runs here, see below |
Defaults: sub-apps enable = true, sso = true, public = false.
Turning on the parent publishes the stack behind SSO on the LAN; turning
the parent off takes everything down regardless of the child toggles.
The surfaces are instances, not separate apps. They nest under the
GenAI card the same way MediaWiki sites nest under MediaWiki: the parent
declares an instances list, each entry becomes a sub-app labelled
genai_<subdomain>.
That label is not a choice — the admin UI derives it as
${parentLabel}_${inst.subdomain} and matches instances by that string.
The array has to exist in /etc/nixos/homefree-config.json. The admin
UI reads and writes services.genai.instances there and merges no Nix
schema defaults into it, so on a box whose config predates this the sub-app
toggles have nothing to write to — they revert a second after being set,
and the Config dialog comes up empty. Seed it once (see below); after that
the UI owns it, exactly as it owns the MediaWiki site list.
Setting parent WITHOUT a matching instances entry is what caused both of
those symptoms. parent does not mean "group these" in HomeFree; it means
"entry in the parent's instances array", and three handlers in
services-module.js hard-code that.
SSO is the whole security story here. Nothing behind these subdomains
authenticates — the portal, ComfyUI and the tool servers were built for a
trusted LAN — so the Caddy gate is the only thing between the internet and
an inference API with a code sandbox on it. sso.kind is metadata the
admin UI reads and gates nothing; the gate is reverse-proxy.oauth2,
derived from each surface's sso toggle. That distinction cost an open
endpoint once.
The genai app runs nothing
No container, no port, no data. Every surface it publishes fronts a service on the machine with the GPU — the portal in particular reads the model store, render directory and eval reports off local disk, so it has to live there. Getting the portal itself onto HomeFree means putting those disk-bound surfaces behind a wire protocol first; that is tracked in genai-server's ROADMAP.
One thing to set on the genai-server side
The portal builds its directory links from the host you are reading it on,
which behind a proxy yields https://ai.<domain>:3000 for Open WebUI — a
port not published there. Tell it where things really are:
services.genai-server.portal.serviceUrls = {
"Open WebUI" = "https://webui.<domain>";
"ComfyUI" = "https://comfy.<domain>";
"SearXNG" = "https://search.<domain>";
"Magentic-UI" = "https://magentic.<domain>";
"Realtime voice" = "https://voice.<domain>";
};
The voice page needs the last one specifically: it asks the portal where the voice service is published, and without it dials the page's own host.
LibreChat runs HERE, and why it is the exception
Every other surface is ingress: Caddy points at a service on the machine with the GPU, and the gate in front is that service's identity. That works because those services take an identity from a header.
LibreChat does not. Its strategies are openid, saml, ldap and local —
no header, no proxy mode — so behind the gate it asked for a SECOND login,
which is the one thing a single sign-on exists to prevent. The only fix is
for LibreChat to speak OIDC to Zitadel itself, and an OIDC client's id and
secret are minted by Zitadel into /var/lib/homefree-secrets/librechat on
this box. Nothing can hand them to the GPU box at evaluation time, and
shipping them afterwards means a hand-copied credential or a new trust edge
between two machines. Running it beside the provider costs neither: it
wants no GPU and reaches the fleet over HTTP like any other client.
It is still a sub-app of GenAI, with no settings of its own — the
app-wide backend-host is where it looks for the stack, exactly like the
surfaces that are only ingress. Two fields in surfaces.nix carry the
difference:
local = true— ingress points at this box, and the port comes from HomeFree's allocator rather than being the remote service's fixed one, since it has to coexist with every other app here.nativeSso = true—sso.kindbecomesnative_oidcandreverse-proxy.oauth2goes off. Three cases, not two: gated by oauth2-proxy, gating itself against the same provider, or neither. This is not an ungated surface — it is one whose gate is its own.
Until Zitadel has provisioned the client the generated oidc.env is empty
and LibreChat keeps its own login rather than offering a sign-in that
cannot complete. It comes up on the restart post_restart_units triggers,
with no second rebuild.
It talks to the stack through one URL, because genai-server's portal is
a single API front door: /v1/* is the fleet (availability gate and GPU
arbiter applied) and /svc/mcp/mcp is the MCP gateway. The portal's port
is read out of the surface table, not written down twice.
The definition is not written here. genai-server exports it as
lib.librechat — the container pair, the generated librechat.yaml and
the two env-file scripts, in the field names homefree.containers.<name>
uses. A second copy of the image tag, the Mongo wiring or that YAML in this
repo is the drift both repos are organised against, and is why this flake
finally takes the input its own comment predicted it would need. Bump it
with nix flake update genai-server — an input is the one thing rsync
cannot carry, since nix will not lock a staged-but-uncommitted tree.
Status
Ingress, plus one container app.
Every surface but one is ingress; genai-librechat runs here. Container
apps for the CPU-capable services (voice, tts, rag, memory, mcp,
media-tools) are next; they use the same input, for lib.serverScripts.