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:14b (default coding model — primary) claude-opus-4-6 → gemma4:e4b (light/fast on-demand) qwen3 → qwen3:14b (native name passthrough) qwen3.6 → qwen3:14b (backward compatibility alias) qwen2.5-coder → qwen2.5-coder:14b (previous default, still pulled) gemma4 → gemma4:e4b (backward compatibility alias)
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.
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, mistral-small3.1) 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-3.1 (and the dependency https://huggingface.co/pyannote/segmentation-3.0). 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 = "mistral-small3.1";
description = "Ollama model name for summarization (must be pulled on the host).";
};
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
items" vs others'.
'';
};
userEmail = lib.mkOption {
type = lib.types.str;
example = "olaf@freundcloud.com";
description = "Your email. Helps the LLM identify you in the transcript.";
};
flagKeywords = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "blocker" "deadline" "urgent" "incident" "risk" "escalate" ];
description = "Keywords extracted with timestamps into the 'Flagged' section of the brief.";
};
}
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
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.6:27b ~17GB, gemma4:26b MoE ~18GB) but NOT both at once (~35GB > 24GB), so MAX_LOADED_MODELS=1 forces deterministic evict-then-load on switch.
Default model choices (May 2026): Persistent: qwen3.6:27b — strong agentic tool calling (Qwen RL-trained on 1M agentic envs), good for Claude Code's tool-use loops. 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.6:27b" ];
description = ''
Models pulled at activation and used as the default coding model.
Listed first in the load priority. Default qwen3.6:27b (~17GB,
strong agentic tool calling).
'';
};
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.
'';
};
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 = ''
Bind address for Ollama's HTTP API. Default 127.0.0.1 (loopback
only). Set to "0.0.0.0" to expose on all interfaces — Ollama has
no auth, so combine with firewall / tailnet ACLs when widening.
'';
};
origins = lib.mkOption {
type = lib.types.str;
default = "*";
description = ''
Value for OLLAMA_ORIGINS — comma-separated list of allowed
browser origins for CORS. Defaults to "*" so any local or remote
web UI can call the API. Tighten if you want browser-origin
restriction (network exposure is controlled by `host`, not this).
'';
};
cloudApiKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
# … 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";
};
}
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/systemd/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.