Services¶
Source directory: modules/services/
default.nix¶
modules/services/appimage/default.nix
No option declarations; see source for implementation.
arr-suite-mcp.nix¶
modules/services/arr-suite-mcp.nix
arr-suite MCP server — exposed as an HTTP/SSE daemon.
arr-suite-mcp (shaktech786) is a stdio-only Python MCP server. To make it a tailnet-reachable daemon we wrap it with mcp-proxy (stdio→SSE):
mcp-proxy --host 0.0.0.0 --port
Clients connect to the SSE endpoint: http://
The *arr API keys are supplied via an agenix EnvironmentFile
(SONARR/RADARR/PROWLARR/OVERSEERR_API_KEY). Hosts/ports default to
localhost:
- Enable option: arr-suite MCP server (SSE daemon via mcp-proxy)
Options declaration (Nix)
options.features.arr-suite-mcp = {
enable = lib.mkEnableOption "arr-suite MCP server (SSE daemon via mcp-proxy)";
port = lib.mkOption {
type = lib.types.port;
default = 3011;
description = "Port the SSE bridge binds to (loopback always; tailnet + LAN via firewall).";
};
environmentFile = lib.mkOption {
type = lib.types.path;
default = config.age.secrets."arr-suite-mcp-env".path;
defaultText = lib.literalExpression ''config.age.secrets."arr-suite-mcp-env".path'';
description = "EnvironmentFile with the *arr API keys (KEY=VALUE per line).";
};
listenLanInterface = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "eno1";
description = ''
LAN interface to open the port on, in addition to tailscale0 and
loopback. null exposes the daemon only via Tailscale. (No effect on
hosts where the firewall is disabled.)
'';
};
}
default.nix¶
modules/services/atuin/default.nix
No option declarations; see source for implementation.
audible-sync.nix¶
modules/services/audible-sync.nix
audible-sync — download + decrypt your Audible library to local .m4b files.
UX: one command, audible-sync, after a one-time interactive login.
Pipeline: library export → bulk download (.aaxc/.aax) → decrypt to .m4b
→ organise into one folder per book under outputDir. Re-runnable;
already-downloaded books and already-decrypted files are skipped.
Per-host wiring (currently p620 only):
features.audibleSync = { enable = true; outputDir = "~/audiobooks/audible"; # default };
One-time setup (run interactively on p620 AFTER deploy): 1. audible quickstart # picks marketplace, handles 2FA in browser 2. audible library list # confirm your books are visible 3. audible-sync # downloads + decrypts everything
Legal note: stripping DRM violates Audible's ToS. Personal-use only.
- Enable option: audible-sync — download + decrypt Audible library to .m4b
Options declaration (Nix)
options.features.audibleSync = {
enable = lib.mkEnableOption "audible-sync — download + decrypt Audible library to .m4b";
outputDir = lib.mkOption {
type = lib.types.str;
default = "~/audiobooks/audible";
description = ''
Destination for decrypted, organised .m4b files (one folder per book).
Tilde is expanded at runtime against the invoking user's $HOME.
'';
};
}
audiobook-import.nix¶
modules/services/audiobook-import.nix
audiobook-import — completed-download → Audiobookshelf import pipeline.
A timer-driven reconciler (modules/services/audiobook-import.py) that scans
the audiobook download dir(s) for stable, completed folders, uses the local
Ollama to parse the release name into structured metadata, optionally merges
multi-file books into a chaptered M4B via m4b-tool, and hardlinks/places the
result into the Audiobookshelf library with a metadata.json. Idempotent via
a .imported marker; sources are left intact so torrent seeding continues.
- Enable option: audiobook download → Audiobookshelf import pipeline
Options declaration (Nix)
options.features.audiobook-import = {
enable = lib.mkEnableOption "audiobook download → Audiobookshelf import pipeline";
watchDirs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "/mnt/media/downloads/torrents/audiobooks" ];
description = "Download directories scanned for completed audiobook folders.";
};
libraryDir = lib.mkOption {
type = lib.types.str;
default = "/mnt/media/Media/Audiobooks";
description = "Audiobookshelf library root to place imported books into.";
};
model = lib.mkOption {
type = lib.types.str;
default = "qwen2.5:7b";
description = "Ollama model used for metadata extraction (strict JSON).";
};
ollamaUrl = lib.mkOption {
type = lib.types.str;
default = "http://127.0.0.1:11434";
description = "Base URL of the local Ollama server.";
};
mergeToM4b = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Merge multi-file audiobooks into a single chaptered M4B via m4b-tool.";
};
stableSeconds = lib.mkOption {
type = lib.types.int;
default = 120;
description = "Skip folders modified more recently than this (still downloading).";
};
interval = lib.mkOption {
type = lib.types.str;
default = "*:0/5";
description = "systemd OnCalendar expression for the import scan (default every 5 min).";
};
user = lib.mkOption {
type = lib.types.str;
default = "olafkfreund";
description = "User to run the import as (must own the library + read downloads).";
};
group = lib.mkOption {
type = lib.types.str;
default = "users";
description = "Group to run the import as.";
};
}
audiobook-mcp.nix¶
modules/services/audiobook-mcp.nix
audiobook-mcp — audiobook acquisition + library MCP server, as an SSE daemon.
audiobook-mcp (pkgs.customPkgs.audiobook-mcp) is a stdio FastMCP server. To make it tailnet-reachable we wrap it with mcp-proxy (stdio→SSE):
mcp-proxy --host 0.0.0.0 --port
Clients connect to the SSE endpoint: http://
Backend URLs default to the local services on p510 and are passed as plain env; the API keys (PROWLARR/SABNZBD/ABS) come from an agenix EnvironmentFile read by systemd as root before dropping to the DynamicUser.
- Enable option: audiobook MCP server (SSE daemon via mcp-proxy)
Options declaration (Nix)
options.features.audiobook-mcp = {
enable = lib.mkEnableOption "audiobook MCP server (SSE daemon via mcp-proxy)";
port = lib.mkOption {
type = lib.types.port;
default = 3012;
description = "Port the SSE bridge binds to (loopback always; tailnet + LAN via firewall).";
};
abbAppUrl = lib.mkOption {
type = lib.types.str;
default = "http://127.0.0.1:5078";
description = "audiobookbay-automated base URL (used to add ABB releases).";
};
prowlarrUrl = lib.mkOption {
type = lib.types.str;
default = "http://127.0.0.1:9696";
description = "Prowlarr base URL (Usenet/torrent indexer search).";
};
sabnzbdUrl = lib.mkOption {
type = lib.types.str;
default = "http://127.0.0.1:8080";
description = "SABnzbd base URL (Usenet grabs).";
};
audiobookshelfUrl = lib.mkOption {
type = lib.types.str;
default = "http://127.0.0.1:13378";
description = "Audiobookshelf base URL (library lookups).";
};
ollamaUrl = lib.mkOption {
type = lib.types.str;
default = "http://127.0.0.1:11434";
description = "Local Ollama base URL (recommend_bestsellers tool).";
};
ollamaModel = lib.mkOption {
type = lib.types.str;
default = "qwen2.5:7b";
description = "Ollama model used for bestseller suggestions.";
};
environmentFile = lib.mkOption {
type = lib.types.path;
default = config.age.secrets."audiobook-mcp-env".path;
defaultText = lib.literalExpression ''config.age.secrets."audiobook-mcp-env".path'';
description = "EnvironmentFile with backend API keys (PROWLARR/SABNZBD/ABS).";
};
listenLanInterface = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "eno1";
description = ''
LAN interface to open the port on, in addition to tailscale0 and
loopback. null exposes the daemon only via Tailscale.
'';
};
}
audiobookbay-automated.nix¶
modules/services/audiobookbay-automated.nix
audiobookbay-automated — AudioBookBay search → Transmission web app.
A small Flask app (pkgs.customPkgs.audiobookbay-automated) that searches
AudioBookBay and sends the chosen release's magnet to the existing
Transmission daemon on p510. Each download is saved to
The app only talks to Transmission's RPC (127.0.0.1:9091, auth disabled on p510) and to AudioBookBay over HTTPS — it writes nothing to disk itself, so the unit runs fully sandboxed (DynamicUser + ProtectSystem=strict).
Note: AudioBookBay distributes copyrighted material without authorization. abbHostname is configurable; the same app works against any compatible host.
- Enable option: AudioBookBay search → Transmission web app
Options declaration (Nix)
options.features.audiobookbay-automated = {
enable = lib.mkEnableOption "AudioBookBay search → Transmission web app";
port = lib.mkOption {
type = lib.types.port;
default = 5078;
description = "Port the Flask UI binds to (loopback always; tailnet + LAN via firewall).";
};
abbHostname = lib.mkOption {
type = lib.types.str;
default = "audiobookbay.lu";
description = "AudioBookBay host to search against.";
};
transmissionHost = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Transmission RPC host.";
};
transmissionPort = lib.mkOption {
type = lib.types.port;
default = 9091;
description = "Transmission RPC port.";
};
savePathBase = lib.mkOption {
type = lib.types.str;
default = "/mnt/media/downloads/torrents/audiobooks";
description = ''
Base directory passed to Transmission as the per-torrent download
location (each book lands in <savePathBase>/<Title>/). Must be
writable by the Transmission service user; watched by the
audiobook-import pipeline.
'';
};
category = lib.mkOption {
type = lib.types.str;
default = "Audiobookbay-Audiobooks";
description = "Download category/label tag.";
};
listenLanInterface = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "eno1";
description = ''
LAN interface to open the port on, in addition to tailscale0 and
loopback. null exposes the UI only via Tailscale.
'';
};
}
backstage.nix¶
modules/services/backstage.nix
Backstage developer portal.
Runs olafkfreund/backstage (a customised Spotify Backstage app) on p510 alongside a sibling Postgres container. Image is consumed from ghcr.io/olafkfreund/backstage, pinned to a SHA digest (NEVER :latest — that's a supply-chain risk: a leaked GHCR token could quietly swap the running image).
Wiring overview:
┌─────────────────────────┐ ┌──────────────────────────┐ │ podman-backstage-postgres│◀────│ podman-backstage │ │ 127.0.0.1:5435 → 5432 │ │ 127.0.0.1:7007 → 7007 │ └─────────────────────────┘ └──────────────────────────┘ ▲ │ Tailscale Serve │ /backstage path ▼ https://p510.tail833f7.ts.net/backstage
Secrets (from agenix, loaded at runtime — never in the Nix store): backstage-postgres-password → POSTGRES_PASSWORD backstage-github-token → GITHUB_TOKEN (catalog integration) backstage-github-oauth-client-id → AUTH_GITHUB_CLIENT_ID backstage-github-oauth-client-secret → AUTH_GITHUB_CLIENT_SECRET
The secret-to-env bridge: a one-shot systemd unit (backstage-env-setup) reads /run/agenix/backstage-* and writes /run/backstage/env-{postgres, backstage}, consumed by the container services as environmentFiles. /run/backstage is tmpfs (cleared on every boot — secrets re-emitted each time the unit runs).
This module is intentionally disabled by default. Flip features.backstage.enable = true on p510 only AFTER: 1. Phase 1 image is in ghcr.io with a real SHA digest 2. Phase 2 agenix secrets exist and have been rekeyed 3. Phase 4 Tailscale Serve route is added See olafkfreund/nixos_config epic #731.
- Enable option: Backstage developer portal
Options declaration (Nix)
options.features.backstage = {
enable = lib.mkEnableOption "Backstage developer portal";
image = lib.mkOption {
type = lib.types.str;
default = "ghcr.io/olafkfreund/backstage@sha256:33a836eb6a7b8d45e5ef240e973ba2dd1856e377a31b1c952fa244b2d3dd70bc";
example = "ghcr.io/olafkfreund/backstage@sha256:abc123...";
description = ''
OCI image to pull for the Backstage backend. MUST be pinned to a
SHA256 digest (the @sha256:... form). Do NOT use :latest — updates
should be explicit nixos commits so a leaked GHCR token can't
quietly swap the running image.
'';
};
postgresImage = lib.mkOption {
type = lib.types.str;
default = "docker.io/postgres:16-alpine";
description = "OCI image for the Postgres sidecar.";
};
port = lib.mkOption {
type = lib.types.port;
default = 7007;
description = "Localhost port the Backstage backend binds to.";
};
pgPort = lib.mkOption {
type = lib.types.port;
default = 5435;
description = ''
Localhost port for Backstage's Postgres. 5435 avoids colliding with
skill-pool's 5434 on p620 (in case that ever migrates to p510) and
with a typical host Postgres on 5432.
'';
};
pgDatabase = lib.mkOption {
type = lib.types.str;
default = "backstage";
description = "Postgres database name.";
};
pgUser = lib.mkOption {
type = lib.types.str;
default = "backstage";
description = "Postgres user.";
};
publicUrl = lib.mkOption {
type = lib.types.str;
default = "https://p510.tail833f7.ts.net/backstage";
description = ''
Public-facing base URL Backstage uses for app.baseUrl,
backend.baseUrl, CORS origin, and OAuth callbacks. If you rename
your tailnet or move Backstage to a different host, update this
AND the GitHub OAuth App's authorization callback URL (which
must match exactly).
'';
};
memoryHigh = lib.mkOption {
type = lib.types.str;
default = "2G";
description = ''
Soft memory cap on the Backstage container (passed as --memory to
podman). Caps blast radius if Backstage leaks memory while Plex
transcode + Ollama are running. Epic #731 risk #3.
'';
};
}
bazarr.nix¶
Bazarr — subtitle manager for Sonarr/Radarr/Lidarr on p510.
Thin wrapper over nixpkgs's services.bazarr. We add a feature flag for
consistency with the other media services, plus narrow the firewall
opening to tailscale0 (and optionally a named LAN interface) instead
of using openFirewall = true which would expose the port globally.
Storage: nixpkgs default /var/lib/bazarr (small config + SQLite db).
Subtitle .srt files are written next to the video files in
/mnt/media, not into Bazarr's own data dir — no extra config needed.
First-deploy UX (one-time, in the Bazarr web UI at http://p510:6767): 1. Settings → Sonarr → add: localhost:8989 + SONARR_API_KEY 2. Settings → Radarr → add: localhost:7878 + RADARR_API_KEY (API keys can be found in arr-suite-mcp-env.age — pasted via UI; Bazarr stores them in its own DB after that) 3. Settings → Languages → enable Norwegian Bokmål (nb) + English (en) 4. Settings → Languages → Default Profile: a. Norwegian Bokmål (forced=False) b. English (forced=False) 5. Settings → Providers → enable OpenSubtitles.com (anonymous works; authenticate later for higher daily quota) 6. Tick "use embedded subs" if present (saves a download when the release already has subs muxed in)
Phase 2 candidate: declarative initial-config via Bazarr's REST API at first deploy, similar to the *arr webhook wiring for media-bot.
- Enable option: Bazarr subtitle manager
Options declaration (Nix)
options.features.bazarr = {
enable = lib.mkEnableOption "Bazarr subtitle manager";
listenLanInterface = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "eno1";
description = ''
LAN interface to also open the Bazarr port on, in addition to
tailscale0. null exposes the service only via Tailscale (the
recommended setting; Bazarr's UI is fine over the tailnet).
'';
};
}
bluetooth.nix¶
modules/services/bluetooth/bluetooth.nix
No option declarations; see source for implementation.
citrix-workspace.nix¶
modules/services/citrix-workspace.nix
- Enable option: Citrix Workspace
Options: enable, package, acceptLicense
Options declaration (Nix)
options.services.citrix-workspace = {
enable = mkEnableOption "Citrix Workspace";
package = mkOption {
type = types.package;
default = pkgs.citrix_workspace;
description = "Citrix Workspace package to use";
};
acceptLicense = mkOption {
type = types.bool;
default = false;
description = ''
Accept the Citrix Workspace End User License Agreement.
WARNING: By setting this to true, you accept the Citrix EULA.
You must manually download the tarball from Citrix if required.
'';
};
}
cloudflared.nix¶
modules/services/cloudflared.nix
Cloudflare Tunnel — public ingress for p510 services from behind Starlink CGNAT.
Outbound-only tunnel from p510 to Cloudflare's edge. No port forwards, no
public IP needed. Each route maps a hostname under freundcloud.org.uk
(Cloudflare-managed zone) to a local URL on p510.
Thin feature-flag wrapper over upstream services.cloudflared. The
credentials.json from cloudflared tunnel create lives in agenix
(cloudflared-credentials.age); the cert.pem from cloudflared login
lives in agenix too (cloudflared-cert.age). Routes are declarative —
add a new entry to cfg.ingress, deploy, optionally run
cloudflared tunnel route dns <tunnel> <hostname> once to add the
Cloudflare DNS CNAME (or do it via dashboard).
One-time bootstrap (run on a workstation with browser access — NOT p510): 1. nix-shell -p cloudflared 2. cloudflared login # → opens browser; saves ~/.cloudflared/cert.pem 3. cloudflared tunnel create p510-home
→ prints tunnel UUID, saves ~/.cloudflared/.json¶
- Copy cert.pem +
.json into agenix via manage-secrets.sh (one secret each: cloudflared-cert, cloudflared-credentials) - Set features.cloudflared.tunnelId to the UUID and deploy
Adding a Cloudflare DNS record for a hostname (one-time per hostname): cloudflared tunnel route dns p510-home argocd.freundcloud.org.uk
or click-route in the Cloudflare Zero Trust dashboard¶
References: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/ https://nixos.wiki/wiki/Cloudflare_tunnel
- Enable option: Cloudflare Tunnel client — public ingress from behind Starlink CGNAT
Options declaration (Nix)
options.features.cloudflared = {
enable = lib.mkEnableOption "Cloudflare Tunnel client — public ingress from behind Starlink CGNAT";
tunnelId = lib.mkOption {
type = lib.types.str;
description = ''
Tunnel UUID issued by `cloudflared tunnel create`. The credentials
file in agenix MUST match this UUID — they are paired.
'';
example = "deadbeef-1234-5678-9abc-def012345678";
};
ingress = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = lib.literalExpression ''
{
"argocd.freundcloud.org.uk" = "http://localhost:80";
"backstage.freundcloud.org.uk" = "http://localhost:7007";
}
'';
description = ''
Map of public hostnames to local service URLs that cloudflared will
proxy. Each hostname MUST also have a Cloudflare DNS CNAME pointing
at <tunnelId>.cfargotunnel.com — created once via:
cloudflared tunnel route dns p510-home <hostname>
or via the Cloudflare Zero Trust dashboard.
Default fallback (`services.cloudflared.tunnels.<id>.default`) is
set below to `http_status:404` so any miss returns a clean 404
rather than leaking that the tunnel exists.
'';
};
keepalive = {
enable = lib.mkEnableOption ''
Periodic GET against each ingress origin to keep apps warm.
Cloudflare opens a fresh TCP connection to the origin per request,
so any app behind the tunnel that idles aggressively (Node SPAs,
gunicorn workers, JVMs, podman containers without --keepalive)
will cold-start on the first hit and the SPA may render blank
while it boots. A 2-minute heartbeat sidesteps this entirely.
Hits the LOCAL origin URLs directly — does not exercise the
cloudflared edge path, just the origin app, which is what needs
keeping warm.
'';
interval = lib.mkOption {
type = lib.types.str;
default = "2min";
example = "5min";
description = ''
systemd `OnUnitActiveSec` interval between keepalive runs.
Default 2min is comfortably under typical idle-recycle windows
(Node `keepAliveTimeout`, gunicorn worker recycling, k8s HPA
scale-to-zero) without generating meaningful load.
'';
};
timeout = lib.mkOption {
type = lib.types.int;
default = 10;
description = ''
Per-origin curl timeout in seconds. Kept short so a single
slow origin doesn't delay the rest of the sweep.
'';
};
};
}
cron.nix¶
modules/services/cron/cron.nix
No option declarations; see source for implementation.
default.nix¶
No option declarations; see source for implementation.
secure-dns.nix¶
modules/services/dns/secure-dns.nix
- Enable option: Secure DNS with enhanced stability
Options: enable, dnssec, useStubResolver, fallbackProviders, cacheSize, dnsOverTls, networkManagerIntegration
Options declaration (Nix)
options.services.secure-dns = {
enable = mkEnableOption "Secure DNS with enhanced stability";
dnssec = mkOption {
type = types.enum [ "true" "false" "allow-downgrade" ];
default = "true";
description = "Whether to enable DNSSEC validation";
example = "allow-downgrade";
};
useStubResolver = mkOption {
type = types.bool;
default = true;
description = "Use systemd-resolved's stub resolver";
example = true;
};
fallbackProviders = mkOption {
type = types.listOf types.str;
default = [
"1.1.1.1#cloudflare-dns.com"
"8.8.8.8#dns.google"
];
description = "List of fallback DNS providers to use";
example = [ "9.9.9.9#dns.quad9.net" ];
};
cacheSize = mkOption {
type = types.int;
default = 4096;
description = "Size of DNS cache in entries";
example = 8192;
};
dnsOverTls = mkOption {
type = types.bool;
default = true;
description = "Whether to enable DNS-over-TLS";
example = true;
};
networkManagerIntegration = mkOption {
type = types.bool;
default = true;
description = "Whether to integrate with NetworkManager";
example = true;
};
}
default.nix¶
modules/services/flaresolverr/default.nix
FlareSolverr Configuration Module A proxy server to bypass Cloudflare protection for web scraping applications
- Enable option: FlareSolverr proxy server
Options: enable, package, port, host, logLevel, logHtml, captchaSolver, testUrl, sessionTtl, headless, browserTimeout, user, group, dataDir, extraEnvironment, openFirewall
Options declaration (Nix)
options.services.flaresolverr = {
enable = mkEnableOption "FlareSolverr proxy server";
package = mkOption {
type = types.package;
default = pkgs.flaresolverr;
defaultText = literalExpression "pkgs.flaresolverr";
description = "The FlareSolverr package to use";
};
port = mkOption {
type = types.port;
default = 8191;
description = "Port on which FlareSolverr will listen";
};
host = mkOption {
type = types.str;
default = "0.0.0.0";
description = "Host address to bind to";
};
logLevel = mkOption {
type = types.enum [ "debug" "info" "warning" "error" ];
default = "info";
description = "Log level for FlareSolverr";
};
logHtml = mkOption {
type = types.bool;
default = false;
description = "Whether to log HTML content";
};
captchaSolver = mkOption {
type = types.enum [ "none" "hcaptcha-solver" "harvester" ];
default = "none";
description = "CAPTCHA solver to use";
};
testUrl = mkOption {
type = types.str;
default = "https://www.google.com";
description = "URL to test browser functionality";
};
sessionTtl = mkOption {
type = types.int;
default = 600000;
description = "Session time-to-live in milliseconds";
};
headless = mkOption {
type = types.bool;
default = true;
description = "Run browser in headless mode";
};
browserTimeout = mkOption {
type = types.int;
default = 40000;
description = "Browser timeout in milliseconds";
};
user = mkOption {
type = types.str;
default = "flaresolverr";
description = "User to run FlareSolverr as";
};
group = mkOption {
type = types.str;
default = "flaresolverr";
description = "Group to run FlareSolverr as";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/flaresolverr";
description = "Data directory for FlareSolverr";
};
extraEnvironment = mkOption {
type = types.attrs;
default = { };
description = "Extra environment variables for FlareSolverr";
example = {
PROMETHEUS_ENABLED = "true";
PROMETHEUS_PORT = "8192";
};
# … truncated — see source link above
flatpak.nix¶
modules/services/flatpak/flatpak.nix
Flatpak Application Management Module Enables Flatpak with automatic Flathub repository setup
- Enable option: Flatpak application management
Options: enable, autoAddFlathub, maxRetries, retryDelay
Options declaration (Nix)
options.modules.services.flatpak = {
enable = mkEnableOption "Flatpak application management";
autoAddFlathub = mkOption {
type = types.bool;
default = true;
description = ''Automatically add Flathub repository on system startup'';
example = false;
};
repositorySetup = {
maxRetries = mkOption {
type = types.int;
default = 3;
description = ''Maximum number of retry attempts for repository setup'';
example = 5;
};
retryDelay = mkOption {
type = types.int;
default = 5;
description = ''Base delay in seconds between retry attempts'';
example = 10;
};
};
}
default.nix¶
modules/services/geforcenow/default.nix
NVIDIA GeForce NOW Cloud Gaming Module Enables GeForce NOW native Linux client via Flatpak
- Enable option: NVIDIA GeForce NOW cloud gaming client
Options: enable, autoInstall, waylandFix, maxRetries, retryDelay
Options declaration (Nix)
options.modules.services.geforcenow = {
enable = mkEnableOption "NVIDIA GeForce NOW cloud gaming client";
autoInstall = mkOption {
type = types.bool;
default = true;
description = ''
Automatically install GeForce NOW Flatpak on system startup.
If disabled, the remote will be added but installation must be done manually.
'';
example = false;
};
waylandFix = mkOption {
type = types.bool;
default = false;
description = ''
Apply Wayland fix by disabling Wayland socket for GeForce NOW.
Enable this if the application window doesn't open on Wayland.
'';
example = true;
};
remoteSetup = {
maxRetries = mkOption {
type = types.int;
default = 3;
description = ''Maximum number of retry attempts for repository setup'';
example = 5;
};
retryDelay = mkOption {
type = types.int;
default = 10;
description = ''Base delay in seconds between retry attempts'';
example = 15;
};
};
}
github-runner.nix¶
modules/services/github/github-runner.nix
No option declarations; see source for implementation.
gnome-services.nix¶
modules/services/gnome/gnome-services.nix
No option declarations; see source for implementation.
greetd.nix¶
modules/services/greetd/greetd.nix
No option declarations; see source for implementation.
home-assistant.nix¶
modules/services/home-assistant.nix
- Enable option: Home Assistant home automation platform
Options: enable, port, enableCloud, enableCLI, extraComponents, tailscaleIntegration, dashboards, title, icon, showInSidebar, yaml
Options declaration (Nix)
options.features.homeAssistant = {
enable = mkEnableOption "Home Assistant home automation platform";
port = mkOption {
type = types.port;
default = 8123;
description = "Port for Home Assistant web interface";
};
enableCloud = mkOption {
type = types.bool;
default = true;
description = "Enable Home Assistant Cloud (Nabu Casa) integration";
};
enableCLI = mkOption {
type = types.bool;
default = true;
description = "Install Home Assistant CLI tool";
};
extraComponents = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Additional Home Assistant components to enable";
};
tailscaleIntegration = mkOption {
type = types.bool;
default = true;
description = "Configure trusted proxies for Tailscale access";
};
dashboards = mkOption {
type = types.attrsOf (types.submodule {
options = {
title = mkOption {
type = types.str;
description = "Sidebar display title";
};
icon = mkOption {
type = types.str;
default = "mdi:view-dashboard";
description = "Material Design Icons name for the sidebar entry";
};
showInSidebar = mkOption {
type = types.bool;
default = true;
description = "Show this dashboard in the HA sidebar";
};
yaml = mkOption {
type = types.lines;
description = ''
Raw Lovelace dashboard YAML. Must define `title:` and `views:`.
Written to /etc/home-assistant/dashboards/<name>.yaml at activation;
referenced from configuration.yaml via lovelace.dashboards.
'';
};
};
});
default = { };
description = ''
Declarative Lovelace dashboards. Each attr key becomes the URL slug
(/<key>) and the corresponding YAML is rendered into a read-only file
under /etc/home-assistant/dashboards/. The default Overview dashboard
remains in storage mode and is unaffected.
'';
};
}
http-time-fallback.nix¶
modules/services/http-time-fallback.nix
- Enable option: HTTPS-based time sync fallback (htpdate) for networks that block NTP (UDP 123)
Options: enable, urls, interval
Options declaration (Nix)
options.services.httpTimeFallback = {
enable = mkEnableOption "HTTPS-based time sync fallback (htpdate) for networks that block NTP (UDP 123)";
urls = mkOption {
type = types.listOf types.str;
default = [
"https://www.cloudflare.com"
"https://www.google.com"
"https://www.kernel.org"
];
description = "HTTPS endpoints htpdate samples to set the clock.";
};
interval = mkOption {
type = types.str;
default = "*:0/15";
description = "systemd OnCalendar expression controlling how often the fallback check runs.";
};
}
intune-portal.nix¶
modules/services/intune-portal.nix
- Enable option: Microsoft Intune Company Portal
Options: enable, package, autoStart, enableDesktopIntegration
Options declaration (Nix)
options.features.intune = {
enable = mkEnableOption "Microsoft Intune Company Portal";
package = mkOption {
type = types.package;
default = pkgs.intune-portal;
defaultText = literalExpression "pkgs.intune-portal";
description = ''
The Microsoft Intune Company Portal package to use.
This defaults to our custom-built package with manual version control.
Change the version by updating pkgs/intune-portal/default.nix and
rebuilding the system.
'';
example = literalExpression "pkgs.intune-portal";
};
autoStart = mkOption {
type = types.bool;
default = false;
description = ''
Whether to automatically start the Intune Portal service on login.
When enabled, the intune-portal service will start with the user session.
When disabled, you must manually launch intune-portal from the application menu.
'';
};
enableDesktopIntegration = mkOption {
type = types.bool;
default = true;
description = ''
Whether to install desktop integration files (.desktop files, system tray support).
This makes Intune Portal available in application menus and provides
system tray integration for enrollment status and notifications.
'';
};
}
default.nix¶
modules/services/kometa/default.nix
Kometa (was Plex Meta Manager) — collections + metadata + posters for Plex.
Runs as a podman OCI container on p510 sharing the host network namespace
so it can reach localhost:32400 (Plex) directly. Container is always-on;
Kometa's internal scheduler fires per the schedule: key in config.yml.
Phase 1a (this commit): dry-run mode + IMDb Top 250 only. Once the
dry-run output looks right, flip dry_run: false in
modules/services/kometa/config.yml and redeploy.
Pattern mirrors modules/services/skill-pool.nix (oci-containers + podman).
Config flow: modules/services/kometa/config.yml ← repo source of truth; contains ${TMDB_API_KEY} + ${PLEX_TOKEN} shell-style placeholders. kometa-config-render.service ← systemd oneshot; envsubst's the template into /var/lib/kometa/config.yml with agenix-provided values. podman-kometa.service ← depends on the oneshot; mounts /var/lib/kometa as /config.
- Enable option: Kometa (Plex Meta Manager)
Options declaration (Nix)
default.nix¶
modules/services/libinput/default.nix
No option declarations; see source for implementation.
litellm-router.nix¶
modules/services/litellm-router.nix
LiteLLM proxy — Anthropic-compat router that fronts the local Ollama service. Lets Claude Code (which speaks the Anthropic API natively) reach local Ollama models by setting ANTHROPIC_BASE_URL per repo.
Architecture: Claude Code → http(s)://p620.../router (LiteLLM) → 127.0.0.1:11434 (Ollama)
Model aliases: claude-sonnet-4-6 → qwen3.8:27b (default coding model — primary) claude-opus-4-6 → gemma4:e4b (light/fast on-demand) qwen3 → qwen3.8:27b (native name passthrough) qwen3.8 → qwen3.8:27b (native name passthrough) qwen3.6 → qwen3.8:27b (backward compatibility alias) qwen2.5-coder → qwen2.5-coder:14b (previous default, still pulled) gemma4 → gemma4:e4b (backward compatibility alias)
The aliases point at whatever host's Ollama is fronted, so they only resolve to a model that host actually pulled — this file is p620's, where qwen3.8:27b is the persistent model (hosts/p620/configuration.nix).
Authentication: a single master bearer key loaded at runtime from agenix (/run/agenix/litellm-master-key). Per-host clients hold the same plaintext under their own .age files (api-router-p620, api-router-razer — Phase 3).
Network: binds 0.0.0.0:4000 but firewall opens it only on tailscale0 and the configured LAN interface — never globally reachable.
See docs/plans/2026-05-22-ollama-p620-litellm-design.md §5 for full design.
- Enable option: LiteLLM proxy fronting the local Ollama service
Options declaration (Nix)
options.features.litellm-router = {
enable = lib.mkEnableOption "LiteLLM proxy fronting the local Ollama service";
port = lib.mkOption {
type = lib.types.port;
default = 4000;
description = "Port LiteLLM binds to (loopback always; tailnet + LAN via firewall).";
};
listenLanInterface = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "enp1s0";
description = ''
LAN interface to open the port on (in addition to tailscale0 and
loopback). Set to the host's actual LAN NIC; confirm with `ip link`.
Set to null to expose only via Tailscale.
'';
};
}
default.nix¶
modules/services/logind/default.nix
No option declarations; see source for implementation.
default.nix¶
modules/services/mandb/default.nix
No option declarations; see source for implementation.
matrix-continuwuity.nix¶
modules/services/matrix-continuwuity.nix
Matrix homeserver — the substrate under the agent bus.
The store behind modules/services/agent-bus-mcp.nix. Agents reach it through four MCP tools; humans reach the same rooms with any Matrix client. That is the whole point of choosing a real chat server over a bespoke message table: one store, two faces. The BBS this replaces had three stores that could not see each other (inbox, news, IRC), which is why nothing could follow a conversation across them.
continuwuity rather than conduwuit: conduwuit was removed from nixpkgs after
upstream deleted the repository, and services.conduwuit survives only as a
removed-option stub that errors on eval. continuwuity is the maintained
continuation and is config-compatible. matrix-tuwunel is the other
successor; either would do.
Federation is OFF. We want accounts on this server talking to each other, not to matrix.org. The client-server API is fully functional without it, and turning it off removes the whole /.well-known/matrix/server and port-8448 surface along with any inbound federation traffic.
- Enable option: Matrix homeserver (continuwuity) backing the agent bus
Options declaration (Nix)
options.features.matrix-continuwuity = {
enable = lib.mkEnableOption "Matrix homeserver (continuwuity) backing the agent bus";
serverName = lib.mkOption {
type = lib.types.str;
example = "example.com";
description = ''
The identity suffix in `@user:<serverName>` and in every room id.
**Permanent.** Changing it later means wiping the database, because
every id already minted embeds it. It does not have to resolve to this
machine and is not the hostname clients connect to — that is
`publicUrl` below, advertised through `/.well-known/matrix/client`.
'';
};
publicUrl = lib.mkOption {
type = lib.types.str;
example = "https://matrix.example.com";
description = ''
Where clients actually reach the homeserver, advertised as
`well_known.client`. This is the tunnel hostname, not `serverName`.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 6167;
description = ''
Loopback port. Never exposed directly: the Cloudflare tunnel proxies
`/_matrix/` to it over HTTPS, which is the tunnel doing the job it is
actually for — unlike SSH, which it cannot carry.
'';
};
registrationTokenFile = lib.mkOption {
type = lib.types.path;
default = config.age.secrets."matrix-registration-token".path;
defaultText = lib.literalExpression ''config.age.secrets."matrix-registration-token".path'';
description = ''
Token gating account creation. Registration is open but token-gated:
continuwuity has no Synapse-style HTTP admin API, so a token is the
only scriptable way to provision accounts — user administration
otherwise happens through commands in an in-Matrix admin room.
Anyone holding this can create an account.
**This is the un-revokable kind, and it is the fallback, not the
published one.** continuwuity is blunt about the file-backed token:
"Tokens set in the registration token file cannot be revoked. Edit the
registration token file and restart Continuwuity to change them." So
rotating *this* value is a commit here plus a homeserver restart, and
the only kill switch in between is `allowRegistration = false`, which
is all-or-nothing.
The token published to outside agents is no longer this one. Since
26.8.1 the homeserver has a database-backed registration-token service
reachable from the admin room, and the public token is issued there:
!admin token issue --max-uses 50 # bounded, no expiry
!admin token list # remaining budget, uses so far
!admin token revoke <token> # takes effect immediately
That token lives in `share/agent-bus/REGISTRATION-TOKEN` in the
nixarchy repo (nixarchy#359), is bounded so a leak costs the unused
remainder rather than unlimited account creation, and is revoked
without touching this file or restarting anything. Reissue and commit
the new value there when it runs out; that is the whole procedure.
'';
};
allowRegistration = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether new accounts can be created with the registration token. Turn
this off once every agent and person that needs an account has one; the
server keeps working and nobody new can appear.
'';
};
}
media-bot.nix¶
modules/services/media-bot.nix
media-bot — household Telegram bot for the *arr stack on p510.
Phase 1 surface (spec: docs/plans/2026-05-30-media-bot-design.md, mirrored at ~/.claude/plans/stateless-enchanting-beaver.md during brainstorm): • menu commands (/search /add /queue /status /recent /wanted) • aiohttp webhook receiver on cfg.port ingesting Sonarr / Radarr / Overseerr / audiobook-import events; replies to Telegram with inline action buttons (Quiet event set — wins only). • Ollama-backed natural-language fallback (qwen2.5:7b by default, override via OLLAMA_MODEL in the env file).
Required secrets (both agenix-encrypted, host-keyed for p510): • media-bot-env.age — TELEGRAM_BOT_TOKEN + arr API keys + OLLAMA_ • media-bot-users.age — YAML user whitelist (telegram_id, plex_user, name)
Pattern mirrors modules/services/arr-suite-mcp.nix: DynamicUser, full systemd hardening, tailscale-only firewall by default.
- Enable option: household media Telegram bot
Options declaration (Nix)
options.features.media-bot = {
enable = lib.mkEnableOption "household media Telegram bot";
port = lib.mkOption {
type = lib.types.port;
default = 8090;
description = ''
Port the aiohttp webhook receiver listens on. Loopback is always
available; the firewall opens this port on `tailscale0` (and
optionally on a named LAN interface) so Sonarr / Radarr / Overseerr
and audiobook-import.service can POST event payloads to it.
'';
};
environmentFile = lib.mkOption {
type = lib.types.path;
default = config.age.secrets."media-bot-env".path;
defaultText = lib.literalExpression
''config.age.secrets."media-bot-env".path'';
description = ''
EnvironmentFile with TELEGRAM_BOT_TOKEN, *arr API keys, OLLAMA_*
endpoint + model (KEY=VALUE per line). The bot reads these on
startup; restart the service to pick up changes.
'';
};
usersFile = lib.mkOption {
type = lib.types.path;
default = config.age.secrets."media-bot-users".path;
defaultText = lib.literalExpression
''config.age.secrets."media-bot-users".path'';
description = ''
YAML file listing whitelisted Telegram users and their Plex
usernames. Reloadable at runtime: `systemctl reload media-bot`
sends SIGHUP, the bot rereads this file, no restart needed.
'';
};
listenLanInterface = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "eno1";
description = ''
LAN interface to additionally open `cfg.port` on. `null` exposes
the webhook receiver only via tailscale0 — recommended, since
Sonarr / Radarr / Overseerr / audiobook-import all run on the
same host as the bot and reach it via 127.0.0.1.
'';
};
}
meeting-transcribe.nix¶
modules/services/meeting-transcribe.nix
meeting-transcribe — one-button meeting recording + transcription + summary.
UX: SUPER+SHIFT+M to start, again to stop. After stop, a background job transcribes (whisperX + diarization) and summarizes (Ollama, gemma4:12b) the audio. ~2-5 min later, notify-send fires with a markdown brief at ~/meetings/YYYY-MM-DD-HHMM.md containing TL;DR, your action items, decisions, flagged keywords, topic timeline, and the full diarized transcript.
Topology: razer records locally + offloads heavy work to p620 over Tailscale SSH. p620 records AND processes locally. Per-host wiring:
razer (client only)¶
features.meetingTranscribe = { enable = true; processHost = "p620"; installProcessor = false; userName = "Olaf"; userEmail = "olaf@freundcloud.com"; };
p620 (client + processor)¶
features.meetingTranscribe = { enable = true; processHost = "local"; installProcessor = true; huggingfaceTokenFile = config.age.secrets."api-huggingface".path; ollamaUrl = "http://localhost:11434"; userName = "Olaf"; userEmail = "olaf@freundcloud.com"; };
Setup (one-time, post-deploy on p620): 1. Sign up at https://huggingface.co/join (free). 2. Accept terms at https://huggingface.co/pyannote/speaker-diarization-community-1 That is what whisperX 3.8.6 requests -- NOT speaker-diarization-3.1, which earlier revisions of this file and the docs both named. 3. Create a read token at https://huggingface.co/settings/tokens. 4. ./scripts/manage-secrets.sh create api-huggingface (paste token). 5. just quick-deploy p620 && just quick-deploy razer.
- Enable option: Meeting recording, transcription, and summarization
Options declaration (Nix)
options.features.meetingTranscribe = {
enable = lib.mkEnableOption "Meeting recording, transcription, and summarization";
processHost = lib.mkOption {
type = lib.types.str;
default = "local";
example = "p620";
description = ''
Where transcription + summarization runs. "local" means same host
(requires installProcessor = true). Otherwise an SSH-reachable host
name (typically a Tailscale node) where the processor is installed.
'';
};
installProcessor = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Install processor-side dependencies (whisperX, meet-process helper).
Set true on the host that runs the heavy lifting (typically p620).
'';
};
huggingfaceTokenFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = lib.literalExpression ''config.age.secrets."api-huggingface".path'';
description = ''
Path to a HuggingFace token file (required by whisperX for the
pyannote diarization model). Required on the processor host. Read
at runtime, never embedded in the store.
'';
};
ollamaUrl = lib.mkOption {
type = lib.types.str;
default = "http://p620:11434";
description = ''
Ollama API base URL for summarization. On p620, override to
http://localhost:11434.
'';
};
ollamaModel = lib.mkOption {
type = lib.types.str;
default = "gemma4:12b";
description = ''
Ollama model used for summarization. Must be declared in
`features.ollama-server` or every brief silently degrades to
transcript-only -- asserted below when Ollama is local.
Sized to fit, not maximised. p620's card has 21.5 GB and
OLLAMA_CONTEXT_LENGTH is 32768, so gemma4:12b (7.6 GB) loads fully
onto the GPU at ~51% VRAM and leaves the rest for the compositor.
qwen3.8:27b was tried first and is the wrong shape here: 17 GB of
weights plus that KV cache overflows the card, ollama splits it
88/12 GPU/CPU, VRAM hits 94% and the desktop freezes for the whole
run. Both return valid JSON of equivalent quality for this task --
it is transcript extraction, not reasoning -- so the larger model
buys nothing and costs a usable machine.
Note OLLAMA_KEEP_ALIVE is 5m: nothing stays resident between
meetings, so every run pays a cold load (24s for gemma4:12b,
~59s for qwen3.8:27b). `persistentModels` only pre-pulls.
'';
};
whisperModel = lib.mkOption {
type = lib.types.str;
default = "large-v3";
description = "whisperX model size: tiny | base | small | medium | large-v3.";
};
language = lib.mkOption {
type = lib.types.str;
default = "en";
description = "Language code for whisperX (e.g. en, no, da).";
};
outputDir = lib.mkOption {
type = lib.types.str;
default = "~/meetings";
description = "Where finished meeting briefs land (per-user; tilde expanded at runtime).";
};
userName = lib.mkOption {
type = lib.types.str;
example = "Olaf";
description = ''
Your display name. Used in the Ollama prompt to identify "your action
# … truncated — see source link above
mtr.nix¶
No option declarations; see source for implementation.
network-stability.nix¶
modules/services/network-stability.nix
- Enable option: Comprehensive network stability improvements
Options: enable, interval, providers, improve, switchDelayMs, startDelay, restartSec, scriptPath
Options declaration (Nix)
options.services.network-stability = {
enable = mkEnableOption "Comprehensive network stability improvements";
monitoring = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable network monitoring";
example = false;
};
interval = mkOption {
type = types.int;
default = 30;
description = "Monitoring interval in seconds";
example = 60;
};
};
secureDns = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable secure DNS configuration";
example = false;
};
providers = mkOption {
type = types.listOf types.str;
default = [
"1.1.1.1#cloudflare-dns.com"
"8.8.8.8#dns.google"
];
description = "List of DNS providers to use";
example = [ "9.9.9.9#dns.quad9.net" ];
};
};
electron = {
improve = mkOption {
type = types.bool;
default = true;
description = "Enable Electron app network stability improvements";
example = false;
};
};
connectionStability = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable network connection stability enhancements";
example = false;
};
switchDelayMs = mkOption {
type = types.int;
default = 5000;
description = "Delay in milliseconds before switching network interfaces";
example = 3000;
};
};
# Add helper service configuration options
helperService = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable the network stability helper service";
example = false;
};
startDelay = mkOption {
type = types.int;
default = 5;
description = "Delay in seconds before starting the network stability service";
example = 10;
};
restartSec = mkOption {
type = types.int;
default = 30;
description = "Time in seconds to wait before restarting the service on failure";
example = 60;
};
};
# Script path option required by network-stability-service.nix
scriptPath = mkOption {
type = types.path;
# … truncated — see source link above
nixarchy-runner.nix¶
modules/services/nixarchy-runner.nix
- Enable option: GitHub Actions runner for the nixarchy repository
Options declaration (Nix)
options.services.nixarchy-runner = {
enable = lib.mkEnableOption "GitHub Actions runner for the nixarchy repository";
url = lib.mkOption {
type = lib.types.str;
default = "https://github.com/olafkfreund/nixarchy";
description = "Repository the runner registers against.";
};
buildDir = lib.mkOption {
type = lib.types.path;
example = "/mnt/games/nix-build";
description = ''
Where nix builds. Deliberately has no default: it is the setting that
decides whether an ISO test finishes or wedges, and the right answer is
a property of the host's disks rather than something this module can
guess.
Pick for sustained write throughput and free space, in that order. A VM
test that runs out of room does not fail cleanly -- qemu takes an I/O
error mid-install and the test hangs until something times out, which
reads as flakiness rather than as a full disk. A slow disk fails the
same way: an SMR drive collapses to single-digit MB/s once its cache
band fills, and a 16 GB VM install is exactly that write pattern, so
pointing builds at the roomiest disk in the machine can reproduce the
hang it was meant to prevent.
Note this is set machine-wide, not just for the runner: there is one
nix daemon, so every build on the host follows. If the store is on a
different filesystem, build output is copied rather than renamed at the
end -- real, and the price of not building on the root filesystem.
'';
};
instances = lib.mkOption {
type = lib.types.ints.positive;
default = 2;
description = ''
How many runners to register, and therefore how many jobs this machine
will accept at once -- a runner takes one job at a time, so this is the
only lever there is.
Two by default. One meant a release build, which owns the machine for
hours, queued every other check behind it; and because GitHub keeps
only one pending run per concurrency group, a third arrival cancelled
the waiting second. The install check went a whole day without a CI
result on a day it passed repeatedly by hand.
Raise it only against cores and RAM you can spare while a VM test is
running. The point is to stop one long job monopolising the machine,
not to parallelise the queue.
'';
};
extraLabels = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "nixos" "kvm" "big" ];
description = ''
Labels the workflow selects on. `nixos` and `kvm` say what the machine
is; `big` says it has room for the ISO test, so a future lighter runner
can carry the same `nixos` label without being handed an hour-long job.
Hosts sharing a label share a queue: GitHub hands a job to whichever
matching runner is free. That is the point when you add a second
machine, and it is also why a host that should not receive heavy jobs
must not claim `big`.
'';
};
minFree = lib.mkOption {
type = lib.types.ints.unsigned;
default = 100 * 1024 * 1024 * 1024;
description = ''
nix.settings.min-free on a runner host, in bytes. The shared 10 GiB
floor is 1% of p620's store disk: nix collected nothing until an
install VM was already short of room, and the failure arrived as a
guest timeout eighty minutes in (nixarchy#663).
'';
};
maxFree = lib.mkOption {
type = lib.types.ints.unsigned;
default = 200 * 1024 * 1024 * 1024;
description = "nix.settings.max-free on a runner host, in bytes.";
};
vmImageDir = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/mnt/data/vmtest";
# … truncated — see source link above
default.nix¶
modules/services/nixos-update-checker/default.nix
- Enable option: NixOS update checker service
Options: enable, flakeDir, checkInterval, enableMotd, user, group
Options declaration (Nix)
options.services.nixos-update-checker = {
enable = mkEnableOption "NixOS update checker service";
flakeDir = mkOption {
type = types.path;
default = "/home/olafkfreund/.config/nixos";
description = "Path to the flake directory to check for updates";
};
checkInterval = mkOption {
type = types.str;
default = "monthly";
example = "weekly";
description = ''
How often to check for updates. Uses systemd timer format.
Common values: daily, weekly, monthly
'';
};
enableMotd = mkOption {
type = types.bool;
default = true;
description = "Enable MOTD (Message of the Day) notifications for available updates";
};
user = mkOption {
type = types.str;
default = "nixos-update-checker";
description = "User to run the update checker service as";
};
group = mkOption {
type = types.str;
default = "nixos-update-checker";
description = "Group for the update checker service";
};
}
ntfy.nix¶
ntfy-sh — self-hosted push notification server.
Wraps the upstream nixpkgs services.ntfy-sh module with a feature flag and injects the agenix-decrypted environment file for auth configuration.
Quick-start after first deploy:
ssh p510 -- sudo ntfy user add --role=admin
Then subscribe on mobile/desktop via https://ntfy.freundcloud.org.uk¶
Sending a notification (example): curl -u user:pass https://ntfy.freundcloud.org.uk/alerts -d "Hello!"
Secrets required (edit before deploy): agenix -e secrets/ntfy-env.age Content: NTFY_AUTH_DEFAULT_ACCESS=deny-all
Reference: https://ntfy.sh/docs/config/
- Enable option: ntfy-sh push notification server
Options declaration (Nix)
options.features.ntfy = {
enable = lib.mkEnableOption "ntfy-sh push notification server";
baseUrl = lib.mkOption {
type = lib.types.str;
example = "https://ntfy.freundcloud.org.uk";
description = "Public-facing base URL (required for attachments and iOS push).";
};
port = lib.mkOption {
type = lib.types.port;
default = 2586;
description = "Local port ntfy-sh listens on (loopback only).";
};
attachmentSizeLimit = lib.mkOption {
type = lib.types.str;
default = "15M";
description = "Maximum size of a single attachment.";
};
attachmentTotalLimit = lib.mkOption {
type = lib.types.str;
default = "2G";
description = "Total attachment cache size on disk.";
};
}
ollama.nix¶
Ollama coding-model server (services.ollama wrapper).
Designed for p620's RX 7900 XTX (gfx1100, 24GB VRAM, ROCm). The single GPU comfortably fits each default model individually (qwen3.8:27b ~18GB, gemma4:26b MoE ~18GB) but NOT both at once (~36GB > 24GB), so MAX_LOADED_MODELS=1 forces deterministic evict-then-load on switch.
Default model choices (Aug 2026):
Persistent: qwen3.8:27b — 18GB Q4, 256K context. DENSE 27B, so unlike a
MoE nothing here is free: everything that spills out of VRAM is paid
for at full width. It stays affordable because the architecture is
hybrid — only 16 of its 64 layers run full attention, the other 48 use
linear attention with a constant recurrent state, so the KV cache
barely grows with context. That is what makes a large contextLength
practical on a 24GB card.
On-demand: gemma4:26b — MoE with ~3.8B active params, very fast
(~80-100 tok/s) for raw code-gen bursts.
Bind address is configurable via host (default 127.0.0.1). Set to
"0.0.0.0" to expose on all interfaces — note Ollama has no built-in
auth, so restrict access via firewall / tailnet ACLs when bound wider.
OLLAMA_ORIGINS="*" is set so browser UIs from any origin can call the
API; this only matters once the bind is non-loopback.
See docs/plans/2026-05-22-ollama-p620-litellm-design.md for the full design and the dual-tier model + GPU-contention rationale.
- Enable option: Ollama coding-model server (loopback only)
Options declaration (Nix)
options.features.ollama-server = {
enable = lib.mkEnableOption "Ollama coding-model server (loopback only)";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.ollama-rocm;
defaultText = lib.literalExpression "pkgs.ollama-rocm";
description = ''
Ollama package. Defaults to `pkgs.ollama-rocm` for AMD GPUs (RDNA3
/ gfx1100 on p620). Switch to `pkgs.ollama-cuda` for NVIDIA hosts.
'';
};
persistentModels = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "qwen3.8:27b" ];
description = ''
Models pulled at activation and used as the default coding model.
Listed first in the load priority. Default qwen3.8:27b (~18GB,
256K context, strong agentic tool calling).
Leave empty on hosts whose GPUs are shared with something else — a
persistent model holds its VRAM until `keepAlive` expires.
'';
};
onDemandModels = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "gemma4:26b" ];
description = ''
Alternate models pulled at activation but only loaded into VRAM on
request. Auto-evicted after `keepAlive` of idle. Default gemma4:26b
(~18GB MoE, ~3.8B active params, very fast raw code-gen).
'';
};
modelsDir = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/mnt/data/ollama/models";
description = ''
Override for where Ollama stores model blobs. Set this to a path
on a large filesystem (~100GB+ recommended) — each Q4 model is
~17-20GB and multiple are typically pulled. When null, NixOS uses
the default under /var/lib/ollama.
'';
};
keepAlive = lib.mkOption {
type = lib.types.str;
default = "5m";
description = ''
Auto-unload models after this idle time. On a workstation host,
keep this low so the GPU is released for desktop work (Blender,
games, video editing) when not actively coding. Use "-1" for
always-loaded if Ollama is the only GPU consumer.
'';
};
contextLength = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = null;
example = 32768;
description = ''
Value for OLLAMA_CONTEXT_LENGTH — the context window the server uses
for requests that do not set `num_ctx` themselves.
Ollama defaults to 4096 no matter what the model supports, so a
256K-context model like qwen3.8:27b is capped at 4096 unless this is
raised. Cost is VRAM for the KV cache on top of the weights, so the
ceiling is the card, not the model — raise it only as far as the
headroom above the resident model allows.
'';
};
rocrVisibleDevices = lib.mkOption {
type = lib.types.str;
default = "0";
description = ''
Comma-separated indices of ROCm-visible devices. Defaults to the
first discrete GPU only; prevents accidental fallthrough to an
integrated GPU on hybrid-graphics systems.
'';
};
host = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
example = "0.0.0.0";
description = ''
# … truncated — see source link above
openssh.nix¶
modules/services/openssh/openssh.nix
No option declarations; see source for implementation.
default.nix¶
modules/services/plex-auto-languages/default.nix
Plex-Auto-Languages (PAL) — per-show audio + subtitle track preference memorization for Plex.
Watches Plex for play events and scan events (via the Plex websocket API, no Plex Pass needed for that path — only the optional webhook integration requires Pass). When a user plays an episode, PAL records the audio/subtitle track choice; future episodes of the same series automatically get those tracks selected on play.
Phase 1: tracking mode, all libraries with shows, no filter labels.
Pattern mirrors modules/services/kometa/default.nix exactly (oci- containers + envsubst-rendered config + agenix env file). The lessons from the Kometa journey are baked in: no fictional systemd deps, real env-var substitution via envsubst-not-Kometa-magic, repo template as source of truth + rendered file under /var/lib for container mount.
- Enable option: Plex-Auto-Languages
Options declaration (Nix)
plex-mcp.nix¶
Plex MCP server — niavasha/plex-mcp-server run as an SSE daemon.
Exposes the local Plex Media Server (and optionally Sonarr/Radarr) to AI
clients over the Model Context Protocol. Clients connect to:
http://
plex-mcp-server's native --transport http uses a single shared session for the process lifetime, which wedges when a client reconnects. We instead run it in stdio mode behind mcp-proxy, which spawns a fresh stdio child per session — robust across reconnects, and consistent with features.arr-suite-mcp.
Network: binds 0.0.0.0:
Secret: the Plex auth token is loaded at runtime from agenix via LoadCredential (never in the Nix store), exported into the wrapper, and passed to the stdio child by mcp-proxy --pass-environment. PLEX_URL is non-secret and set as a plain unit Environment value.
- Enable option: Plex MCP server (HTTP transport daemon)
Options declaration (Nix)
options.features.plex-mcp = {
enable = lib.mkEnableOption "Plex MCP server (HTTP transport daemon)";
port = lib.mkOption {
type = lib.types.port;
default = 3010;
description = "Port the MCP server binds to (loopback always; tailnet + LAN via firewall).";
};
plexUrl = lib.mkOption {
type = lib.types.str;
default = "http://127.0.0.1:32400";
description = "URL of the Plex Media Server the MCP server talks to.";
};
tokenFile = lib.mkOption {
type = lib.types.path;
default = config.age.secrets."plex-token".path;
defaultText = lib.literalExpression ''config.age.secrets."plex-token".path'';
description = ''
Path to a file containing ONLY the Plex auth token. Loaded into the
unit at runtime via LoadCredential. Defaults to the agenix secret
declared by this module.
'';
};
enableMutativeOps = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Enable Plex write/mutative tools (PLEX_ENABLE_MUTATIVE_OPS). Disabled
by default for safety — read-only tools only.
'';
};
listenLanInterface = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "eno1";
description = ''
LAN interface to open the port on, in addition to tailscale0 and
loopback. Set to the host's actual LAN NIC (confirm with `ip link`).
null exposes the server only via Tailscale.
'';
};
}
default.nix¶
modules/services/power/default.nix
No option declarations; see source for implementation.
default.nix¶
modules/services/print/default.nix
Options: enable
Options declaration (Nix)
rescreenshot-mcp.nix¶
modules/services/rescreenshot-mcp.nix
- Enable option: rescreenshot-mcp MCP server for Claude Desktop
Options: enable, package, user, logLevel, autoConfigureClaudeDesktop
Options declaration (Nix)
options.services.rescreenshot-mcp = {
enable = mkEnableOption "rescreenshot-mcp MCP server for Claude Desktop";
package = mkOption {
type = types.package;
default = pkgs.callPackage ../../pkgs/rescreenshot-mcp { };
description = "The rescreenshot-mcp package to use";
};
user = mkOption {
type = types.str;
default = "olafkfreund";
description = "User to configure Claude Desktop for";
};
logLevel = mkOption {
type = types.str;
default = "info";
example = "debug";
description = "Log level for rescreenshot-mcp (trace, debug, info, warn, error)";
};
autoConfigureClaudeDesktop = mkOption {
type = types.bool;
default = true;
description = "Automatically configure Claude Desktop with rescreenshot-mcp";
};
}
sqlite-backup.nix¶
modules/services/sqlite-backup.nix
- Enable option: Periodic sqlite backup of application databases
Options: enable, databases, readPaths, optional, readGroups, destination, keep, dates
Options declaration (Nix)
options.features.sqlite-backup = {
enable = mkEnableOption "Periodic sqlite backup of application databases";
databases = mkOption {
type = types.listOf types.str;
default = [
"${plexDbDir}/com.plexapp.plugins.library.db"
"${plexDbDir}/com.plexapp.plugins.library.blobs.db"
];
description = ''
Absolute paths of the SQLite databases to snapshot. A missing file is
skipped with a warning rather than failing the run, so a service that
has not started yet does not break the others.
'';
};
readPaths = mkOption {
type = types.listOf types.str;
default = [ config.services.plex.dataDir ];
description = ''
Directories the unit may read. ProtectSystem=strict hides everything
else, so any path holding a listed database must appear here.
'';
};
optional = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
Databases whose failure must not fail the run. For a source sitting on
known-bad media: the read is still attempted every night — a pending
sector sometimes becomes readable again, and a reallocation on write
can retire it — but an unrecoverable error is logged as a warning
rather than turning the unit red. Reserve this for data that can be
rebuilt; a database listed here is one nobody is watching.
'';
};
readGroups = mkOption {
type = types.listOf types.str;
default = [ config.services.plex.group ];
description = ''
Groups the unit joins. Every directory on the path to a listed database
must be traversable by one of them: a DynamicUser belongs to no group
by default, and a 0700 service data directory turns a perfectly
readable database into "not found" from inside the namespace — the
stat fails on the directory, not the file.
'';
};
destination = mkOption {
type = types.str;
default = "/mnt/img_pool/backups/sqlite";
description = ''
Where snapshots are written. MUST be a different physical disk from
services.plex.dataDir — the entire point is surviving that disk.
'';
};
keep = mkOption {
type = types.int;
default = 7;
description = "How many timestamped snapshots to retain.";
};
dates = mkOption {
type = types.str;
default = "03:30";
description = ''
systemd OnCalendar expression. Defaults to a quiet hour: the backup
reads ~1GB off a disk that is already struggling, so it should not
compete with evening playback.
'';
};
}
syncthing.nix¶
modules/services/syncthing.nix
- Enable option: Syncthing file synchronization
Options: enable, user, syncHosts, deviceIds, masterHost, syncClaude, syncGemini, guiAddress, openFirewall
Options declaration (Nix)
options.features.syncthing = {
enable = mkEnableOption "Syncthing file synchronization";
user = mkOption {
type = types.str;
default = "olafkfreund";
description = "User to run Syncthing as";
};
syncHosts = mkOption {
type = types.listOf types.str;
default = [ "p620" "razer" "p510" ];
description = "List of hosts to sync with";
};
deviceIds = mkOption {
type = types.attrsOf types.str;
default = { };
description = ''
Device IDs for each host. Get these by running `syncthing --device-id`
on each host after initial Syncthing setup.
'';
example = {
p620 = "ABCDEFG-HIJKLMN-OPQRSTU-VWXYZ12-3456789-ABCDEFG-HIJKLMN-OPQRSTU";
};
};
masterHost = mkOption {
type = types.str;
default = "p620";
description = "Primary host for conflict resolution (introducer)";
};
syncClaude = mkOption {
type = types.bool;
default = true;
description = "Sync ~/.claude directory";
};
syncGemini = mkOption {
type = types.bool;
default = true;
description = "Sync ~/.gemini directory";
};
guiAddress = mkOption {
type = types.str;
default = "127.0.0.1:8384";
description = "Address for Syncthing Web UI";
};
openFirewall = mkOption {
type = types.bool;
default = true;
description = "Open firewall ports for Syncthing";
};
}
default.nix¶
modules/services/sysprof/default.nix
No option declarations; see source for implementation.
default.nix¶
modules/services/system/default.nix
No option declarations; see source for implementation.
default.nix¶
modules/services/tabby/default.nix
No option declarations; see source for implementation.
torrent-vpn.nix¶
modules/services/torrent-vpn.nix
Transmission confined to a ProtonVPN WireGuard network namespace.
Peer traffic leaves exclusively through the tunnel. The kill switch is structural rather than a firewall rule: the namespace has no route to the internet except wg-proton, so a dead tunnel means dead torrents instead of leaked ones. Everything else on the host — Plex, NFS, Tailscale, cloudflared, NZBGet, SABnzbd — keeps using the normal route and is untouched.
root namespace torrent namespace ────────────── ───────────────── eno1 ── internet wg-proton ── default route (VPN only) :9091 socket-proxy ───── veth ───→ transmission RPC
The RPC proxy is what keeps this a drop-in change: cloudflared, the *arr download-client settings, audiobookbay-automated, and LAN/tailnet clients all keep talking to the same host:9091 they did before.
Only the plaintext interface is moved into the namespace; the encrypted UDP socket stays in the root namespace and uses the host's ordinary route. See https://www.wireguard.com/netns/.
- Enable option: Transmission confined to a ProtonVPN WireGuard namespace
Options declaration (Nix)
options.features.torrentVpn = {
enable = lib.mkEnableOption "Transmission confined to a ProtonVPN WireGuard namespace";
address = lib.mkOption {
type = lib.types.str;
example = "10.2.0.2/32";
description = ''
The `Address` field of the ProtonVPN WireGuard config. Unique to the
downloaded config; not secret.
'';
};
gateway = lib.mkOption {
type = lib.types.str;
default = "10.2.0.1";
description = ''
Tunnel gateway, which is also the `DNS` field of the ProtonVPN config
and its NAT-PMP responder. Name resolution inside the namespace must go
through here — the host's stub resolver is not reachable from it.
'';
};
privateKeyFile = lib.mkOption {
type = lib.types.path;
description = ''
Runtime path to a file holding the `PrivateKey` of the ProtonVPN
config, and nothing else. Read at service start, never at evaluation.
'';
};
peer = {
publicKey = lib.mkOption {
type = lib.types.str;
description = "The `PublicKey` of the ProtonVPN config's `[Peer]` section.";
};
endpoint = lib.mkOption {
type = lib.types.str;
example = "185.159.157.1:51820";
description = "The `Endpoint` of the ProtonVPN config's `[Peer]` section.";
};
};
}
whatsapp-bridge.nix¶
modules/services/whatsapp-bridge.nix
WhatsApp Bridge Systemd Service Persistent background service for WhatsApp Web API connection Follows docs/NIXOS-ANTI-PATTERNS.md security patterns
No option declarations; see source for implementation.
whisper-server.nix¶
modules/services/whisper-server.nix
whisper-server — local Whisper transcription HTTP API
Wraps whisper-server from pkgs.whisper-cpp. Used by voice-input clients
on razer + p620 to convert speech-to-text via a hold-to-talk hotkey.
Default deployment: p620 hosts the server (it has the CPU/GPU budget), razer reaches it over the tailnet. p510 is irrelevant — headless.
Service surface: POST http://p620:9300/inference multipart file=@audio.wav → transcript
Designed to be cheap to run idle: the binary memory-maps the model and only burns CPU when a request comes in.
- Enable option: Whisper.cpp HTTP transcription server
Options declaration (Nix)
options.features.whisper-server = {
enable = lib.mkEnableOption "Whisper.cpp HTTP transcription server";
port = lib.mkOption {
type = lib.types.port;
default = 9300;
description = "TCP port for the HTTP inference endpoint.";
};
model = lib.mkOption {
type = lib.types.str;
default = "base.en";
example = "small.en";
description = ''
ggml model name (passed to whisper-cpp-download-ggml-model). Sizes:
tiny.en ≈ 40 MB, fastest, lowest accuracy
base.en ≈ 150 MB, good balance for short dictation
small.en ≈ 500 MB, more accurate, slower
medium.en ≈ 1.5 GB
'';
};
openFirewallOnTailscale = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Open the service port on the tailscale0 interface only.";
};
}
xdg-portal.nix¶
modules/services/xserver/xdg-portal.nix
XDG Desktop Portal Configuration Module Configures desktop integration for Wayland and X11 applications
- Enable option: XDG desktop portal services
Options: enable, backend, enableScreencast, suppressIconWarning, forcePortalOpen
Options declaration (Nix)
options.modules.services.xdg-portal = {
enable = mkEnableOption "XDG desktop portal services";
backend = mkOption {
type = types.enum [ "sway" "gnome" "cosmic" ];
default = "gnome";
description = ''Primary desktop environment backend for portals'';
example = "sway";
};
enableScreencast = mkOption {
type = types.bool;
default = true;
description = ''Enable screencasting support through portals'';
example = false;
};
suppressIconWarning = mkOption {
type = types.bool;
default = true;
description = ''Suppress XDG icon protocol warnings'';
example = false;
};
forcePortalOpen = mkOption {
type = types.bool;
default = true;
description = ''Force applications to use portal for file operations'';
example = false;
};
}
xdg.nix¶
modules/services/xserver/xdg.nix
No option declarations; see source for implementation.