Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

selfhost-nix is a set of opinionated NixOS modules for a single-admin selfhost: declare a service once and get ingress, authentication, secrets, monitoring, a dashboard tile, backups, and notifications from that one definition. Each bundled default sits behind a neutral contract you can swap, and everything is off until you enable it.

⚠️ Built for a private network (LAN or VPN). Nothing here is hardened for the public internet. Exposing a service is yours to design and secure, and it is out of scope.

One person’s fleet, shared as a reference and starting point. Opinionated and still unstable, so you fork to vary the rest.

New here? Getting started installs it, Concepts explains how it fits together, Recipes wire a service end to end, and the Options reference lists every selfhost.* option.

Getting started

⚠️ These services are meant for a private network: LAN or VPN, not the public internet. The defaults harden nothing for internet exposure. Doing it safely is entirely your responsibility and is out of scope. Don’t port-forward 80/443 to this host and assume it’s safe.

Add the flake input

# flake.nix
inputs.selfhost-nix.url = "github:bphenriques/selfhost-nix";
inputs.selfhost-nix.inputs.nixpkgs.follows = "nixpkgs";

Import the module into a host:

imports = [ inputs.selfhost-nix.nixosModules.default ];

Turn things on

Set selfhost.ingress.domain if you route anything publicly, then enable the providers you want: ingress.traefik.enable, auth.oidc.pocket-id.enable, notify.ntfy.enable, monitoring.enable. The notify provider names no topics, so declare at least one (notify.topics.<name>) and point notify.defaultTopic at it. Now register services with selfhost.services.<name>. Registering wires the cross-cutting parts (route, auth, dashboard tile, healthcheck, secrets). It does not run the service. You enable the upstream services.<name> and connect the values it derives.

Recipes wires one end to end, Concepts explains the model, and Bundled apps covers the ones that come ready-wired.

Prerequisites

  • A flake on nixpkgs unstable, with selfhost-nix.inputs.nixpkgs.follows = "nixpkgs".
  • A secrets backend (sops-nix, agenix, or plain files). The framework is path-based: every secret option takes a file path, never a value, so nothing secret reaches the Nix store. You wire the paths, and the backend is yours.

Concepts

One idea underpins everything: declare a service once, and every cross-cutting concern reads from that single definition. This page is the model. The options reference is the per-option truth.

State the framework owns on disk — the secrets directory, backup trees, share mount points, the units it generates — is prefixed homelab-. That is a naming convention, not a second namespace: every option lives under selfhost..

The service registry

selfhost.services.<name> registers a service: the systemd units it owns, usually a backend (host/port), and a derived public url (https://<subdomain>.<domain>). Registering isn’t running. You enable the upstream services.<name> and wire the values and secret files it derives (see Recipes). That one entry is what capabilities attach to:

  • ingress.enable: a reverse-proxy route at the public URL. On once the entry has a backend to route to and its access.model is satisfied (see below). Not the same as on by default.
  • access.model: who authenticates this service’s users (see below).
  • systemdServices: the units concerns attach to, such as storage automount guards. Defaults to the service’s own name, or its container’s unit.
  • integrations.homepage / .monitoring / .notify: a dashboard tile, health and metrics probes, and a publishing credential for notifications. The first two default to their concern: enable monitoring globally and every service opts in (a tile follows having a route). Notify does not, since most services have nothing to send: one publishes once it has a topic.
  • backup.package: a pre-backup hook a target picks up.

That split holds for services you register. It does not hold for bundled apps (selfhost.apps.<name>) or for the providers a concern brings with it: Traefik, Prometheus, Pocket ID, tinyauth, ntfy and the SMB server each own their upstream services.<name> outright. You can still add keys alongside what they set, but replacing one needs lib.mkForce, since both are definitions of the same option.

Not every entry owns a backend. Leave port unset for something that serves no HTTP at all — WireGuard registers for its metadata and metrics, nothing more. Set backend to another entry’s name to put a second hostname onto one process, with its own access model and middlewares: that is how Radicale serves dav.<domain> on htpasswd while its web UI sits behind the gateway. Only an entry that owns its socket counts toward the port-collision check.

Data the framework doesn’t model goes in extraConfig, a freeform slot on the entry that selfhost-nix never reads. Attach your own per-service metadata there (a landing-page category, say) rather than a separate tree keyed by service name, so it rides the same entry as the service. Read it back at config.selfhost.services.<name>.extraConfig, and a consumer module can give it a type.

selfhost.external.<name> puts things this host doesn’t run (a NAS) on the dashboard, without a route or backend. Public hosts and listening ports are checked across the whole registry by one assertion, so two services can’t silently collide.

selfhost.tasks.<name> is the same shape for work that runs on a schedule rather than serving traffic — backups, provisioning, DNS updates. It names the units it owns and requests the same storage and notify integrations. Opting into notify gets it a failure alert, which fires once systemd has given up rather than on an attempt it is about to retry.

Topics are yours to name: nothing here registers or defaults one, and a topic in notify.topics that no publisher writes to is an error. notify.defaultTopic is the fallback every task takes; a service names its own, since most have nothing to send. failureTopic splits a task’s alerts from its routine summaries, the topic being all a subscriber can mute.

ntfy reconciles the declared half on every start, deleting the rows it provisioned and rebuilding them: retargeting leaves no stale grant, and making a topic private revokes it. Readers are runtime, so ntfy-manage reader add <name> grants read on the private topics and shows a password once, reader remove revokes with no deploy, and status lists the lot. They survive the reconcile, which only deletes rows ntfy owns.

Anything that renders your services — a landing page, a fleet view across hosts — reads the registry directly. An entry already carries displayName, meta.*, ingress.enable and publicUrl, and extraConfig is where your own presentation data rides along on the same entry.

Concerns & contracts

A cross-cutting concern is a provider-neutral interface (selfhost.<concern>) that other modules read, filled by at most one implementation (selfhost.<concern>.<impl>.enable). To swap it, disable the bundled one and set the interface yourself. Defaults: Traefik (ingress + TLS), Pocket-ID (OIDC), tinyauth (forward-auth), ntfy (notifications). Some concerns have no interface, because the tool is the contract (Prometheus + Alertmanager, rustic backups, CIFS storage). Disable it and handle the concern yourself.

Authentication

Every entry declares who authenticates its users, as access.model:

  • oidc — the service is its own client of the provider, and users sign in there. Configure the client under access.oidc.
  • forwardAuth — the edge authenticates first, for services with no SSO of their own.
  • native — the service logs users in itself, by a mechanism the framework doesn’t manage (Jellyfin).
  • open — nobody authenticates.

One value, not a set of flags, because these are alternatives: a service is gated one way. Only the first two put the framework in the request path, so only they enforce access.allowedGroups (empty = any authenticated user). Under the other two the warning tells you the groups are decorative.

The model also decides whether a route appears at all. forwardAuth is the one model where the service can authenticate nobody on its own, so it is routed only while a forward-auth provider is active — without one it stays on localhost instead of going up unguarded. The rest carry their own login and route regardless.

Clients, users, and groups are provisioned at boot, and credentials reach a service via LoadCredential or a supplementary group, never the Nix store. Identities and their tiers are the Users model.

First-party apps

A first-party app (selfhost.apps.<name>.enable, default-off) is a bundled application. Most register a selfhost.services.<name> entry and inherit everything above from one toggle, framework-derived wiring (users, storage, reconcilers) included. Most are HTTP behind ingress. A few aren’t: WireGuard is an ingress-less UDP server, and deSEC a headless DDNS timer. The catalog and each app’s options are in the reference.

Secrets outside the store

runtimeSecrets generates values at boot into a persistent directory, never the Nix store or your secrets backend. Each takes a missing-file policy: regenerate, leave absent, or generate-once for data-bound keys (see the options). runtimeTemplates render config that must embed a secret into tmpfs, standing the value in as a named placeholder so it never reaches the store. Rotation is deliberate: remove the value and restart its generator (oidc-rotate wraps this for OIDC clients).

Which policy a secret wants follows from what it protects. A value the host can re-apply at will — an API key a reconciler pushes back into its app — may regenerate whenever it goes missing. A value that encrypts data at rest may not: a fresh key orphans the data it was meant to open. Those set generateOnce to the path of the data they protect, and if the key is gone while that data is still there, the secret is left absent with a log line saying why. Restore the key rather than letting a rebuild manufacture a new one. A wiped host with no data to orphan generates cleanly on the next boot.

Storage & dashboard tiles

storage.mounts.smb.shares are on-demand CIFS shares behind per-share access groups. Boot does not wait for the SMB server. First access may wait up to 30 seconds. After a failed mount, a later access retries. A service requests storage.mounts = [ … ] to start its automount guards before the service can touch their paths. The service still owns its failure and restart policy. Naming the users it runs as in storage.users also grants them each share’s access group, so a share is added in one place rather than two. A share with an owner uid is skipped there, because its owner already reaches the files through the owner bits. That means a second consumer of an owned share still needs the group granted by hand, and it fails loudly when you forget. Converting a share that a previous generation boot-mounted takes one reboot: systemd cannot install autofs over an already-mounted path. Dashboard tiles come from services and externals that opt into integrations.homepage, grouped by group. The bundled apps.homepage renders them, or read the read-only dashboards.generatedTiles into your own. The framework supplies the data, you own the visuals.

Exposure

Enabling an ingress implementation opens 80/443, since a proxy nobody can reach serves no purpose. Narrow that to your LAN and VPN links with ingress.allowedInterfaces, or set ingress.openFirewall = false and place the rules yourself. A single wildcard cert (*.<domain>) comes over ACME DNS-01, so issuance needs no inbound port. Putting services on the public internet is out of scope. There is no bundled hardening, and it is a security-sensitive decision you own.

Users

selfhost-nix models people and service identities as selfhost.users.<name>, across three access tiers via groups: admin, users, guests. The same groups gate services (access.allowedGroups) and SMB shares (storage.shares.smb.shares.<name>.access.groups). At least one admin user is asserted (more is your call). Per-user attributes mirror the framework’s registry, so where an option lives tells you what it touches:

  • A user’s per-service config sits at selfhost.users.<name>.services.<service> — for any service, bundled app or one you registered yourself — mirroring selfhost.services.<service>. (selfhost.apps.<name> is a deploy shortcut with no per-user surface, so per-user always belongs to the service.)
  • A cross-cutting concern’s per-user options sit at selfhost.users.<name>.<concern>, mirroring selfhost.<concern>, e.g. auth.oidc.enable.
  • The same per-principal options are declared on selfhost.serviceAccounts.<name>, so a machine and a person are configured the same way. See Shares for storage.smb.
selfhost.users.alice = {
  groups = [ "admin" ];
  services.filebrowser-quantum = { enable = true; storage = { … }; }; # per-user config for that service
  auth.oidc.enable = true;                                      # mirrors selfhost.auth.oidc
  storage.smb.enable = true;                                    # mirrors selfhost.storage.shares.smb
};

Service accounts

selfhost.serviceAccounts.<name> is the same registry for non-human principals. They hold no groups, so grants name them directly, and the framework provisions identity and the credentials you declare, never authorization inside the application: whatever the account is then allowed to do is set where that application keeps its permissions.

selfhost.serviceAccounts.personal-agent = {
  description = "An agent's forge principal, for git over SSH";
  services.gitea = {
    enable = true;
    sshKeys = [ { key = "ssh-ed25519 AAAA… agent@host"; } ];  # public half only; readOnly = true for a deploy key
  };
};

So a usable account is three steps: declare it with its public key, deploy, then grant it what it needs in the application itself. See Gitea for how that lands and how to read back what an account holds.

Extending per-user as a consumer

To give your own registered service a per-user surface, declare it at selfhost.users.<name>.services.<service> — the same place a bundled app declares its own, mirroring the top-level selfhost.services.<service>. Your data rides on the same user object as its identity, so there is no parallel user tree to join and identity stays single-source.

# your module: a typed per-user fragment for your service
options.selfhost.users = lib.mkOption {
  type = lib.types.attrsOf (lib.types.submodule {
    options.services.jellyfin.enable = lib.mkEnableOption "Jellyfin account for this user";
  });
};

Read it back off config.selfhost.users.<name>, which carries both the framework’s username/isAdmin/… and your per-service options:

lib.filterAttrs (_: u: u.services.jellyfin.enable) config.selfhost.users

For per-user data with no service to hang it on, use the never-read passthrough selfhost.users.<name>.extraConfig instead.

Shares

selfhost.storage.shares.smb serves shares over SMB, the counterpart to the storage.mounts.smb client. It exports and guards paths; it never creates them. The backing store stays yours: a ZFS dataset, a BTRFS subvolume, a plain directory, mounted before the units below run.

Two things are separate on purpose. Holding an account is a property of a principal, so it lives on selfhost.users or selfhost.serviceAccounts. Being let into a share is a property of the share, so grants live next to the path. Neither implies the other: a share may be owned by a principal holding no grant on it, which is what you want when its data is reached through an application rather than over SMB.

selfhost = {
  users.alice = {
    groups = [ "family" ];                    # the same group that gates services
    storage.smb = {
      enable = true;
      passwordFile = config.sops.secrets."samba/alice-password".path;
    };
  };

  serviceAccounts.machine-backup = {
    unixAccount.enable = true;                 # smbd drops to the connecting user, so it needs one
    storage.smb = {
      enable = true;
      passwordFile = config.sops.secrets."samba/machine-backup-password".path;
    };
  };

  storage.shares.smb = {
    enable = true;
    openFirewall = true;
    shares.media = {
      path = "/srv/storage/media";
      owner = "alice";
      gid = 990;
      directories = [ "movies" ];             # created, or ownership-fixed if the host mounted them
      access = {
        groups.family = "rw";
        users.machine-backup = "ro";
      };
    };
  };
};

Who gets in

access.groups names groups from selfhost.users.<name>.groups, the same ones access.allowedGroups uses to gate services, so one group name drives both. It is additive: a principal holding several groups gets the most permissive grant among them.

access.users names principals directly and takes precedence over whatever the groups produced for them, in either direction. "ro" downgrades a group’s "rw", and "none" revokes the grant outright:

access = {
  groups.family = "rw";
  users = {
    machine-backup = "ro";                    # service accounts hold no groups, so they go here
    teenager = "none";                        # in `family`, but not on this share
  };
};

There is deliberately no "none" under groups: a group grant is taken away per principal, not by a second group. The resolved list is what reaches samba, so testparm shows the expanded names rather than a group reference you would have to resolve by hand.

What it asserts

Samba fails open in two ways this module refuses to let you reach. A share whose access resolves to nobody emits an empty valid users, which samba reads as no restriction, admitting every principal that can authenticate. And a share named global would replace the global section, dropping every hardening line with it. Both are assertions, as are: a grant to a principal with no SMB account (samba silently never matches such a name), a grant to a group nobody is in, a principal or owner with no Unix user, and an enabled principal with no passwordFile.

A group that is known but that no SMB principal holds warns instead of failing, since it names the one way this indirection disappoints quietly: the members need storage.smb.enable before a group grant reaches them.

The passdb is reconciled, not just appended: an account that is no longer a declared principal is deleted, so revoking access is a config change rather than a config change plus pdbedit -x.

gid is optional and warns while unset. Pin it once the share holds data: the group allocation is recorded only on the root filesystem, and the data outlives it.

Filesystem-specific bits

One: path. Everything else samba already exposes stays samba’s. Windows “Previous Versions” is the example — this module sets no shadow:* keys, so a host adds them for its own snapshot layout:

services.samba.settings = lib.mapAttrs (_: _: {
  "vfs objects" = "shadow_copy2";
  "shadow:snapdir" = ".zfs/snapshot";          # BTRFS would name its own
  "shadow:snapdirseverywhere" = "yes";
  "shadow:format" = "autosnap_%Y-%m-%d_%H:%M:%S_hourly";
}) cfg.shares;

shadow:format must match a whole snapshot name; a pattern matching part of one surfaces nothing at all.

Overriding generated samba settings

The per-share masks (0660/2770), browseable and the hardened global section are not options. You can add keys to a generated section through services.samba.settings.<share>, but replacing a key this module sets needs lib.mkForce, since both are definitions of the same option.

Clients must speak SMB 3.1.1: server min protocol = SMB3 is a samba alias for SMB3_11, not SMB3_00.

Recipes

First-party apps pre-wire a curated few. This is the other side: how you register a service the framework doesn’t bundle, doing by hand the wiring an app does for you. For real, always-current examples see Examples in the wild.

selfhost.services.<name> registers a service: route, OIDC client, dashboard tile, healthcheck, and backup hook. It does not run it. You enable the upstream service and feed it the values the framework derives and the secret files it generates.

Wiring a service (Miniflux + OIDC)

{ config, lib, ... }:
let
  svc = config.selfhost.services.miniflux;
  oidc = config.selfhost.auth.oidc;
in
{
  # 1. Register: route, OIDC client (admins only), tile, healthcheck.
  selfhost.services.miniflux = {
    port = 8081;
    healthcheck.path = "/healthcheck";
    access.allowedGroups = [ config.selfhost.groups.admin ];
    access.model = "oidc";
    access.oidc.systemd.dependentServices = [ "miniflux" ]; # start after its client is provisioned
    integrations.homepage.enable = true;
  };

  # 2. Run it, fed by the derived values + generated secret files.
  services.miniflux = {
    enable = true;
    createDatabaseLocally = true;
    config = {
      LISTEN_ADDR = "127.0.0.1:${toString svc.port}";
      BASE_URL = svc.publicUrl;
      OAUTH2_PROVIDER = "oidc";
      OAUTH2_USER_CREATION = 1;
      OAUTH2_OIDC_DISCOVERY_ENDPOINT = oidc.provider.issuerUrl;
      OAUTH2_OIDC_PROVIDER_NAME = oidc.provider.displayName;
      OAUTH2_REDIRECT_URL = builtins.head svc.access.oidc.callbackURLs;
      OAUTH2_CLIENT_ID_FILE = svc.access.oidc.id.file; # provisioned at boot
      OAUTH2_CLIENT_SECRET_FILE = svc.access.oidc.secret.file; # never in the Nix store
    };
  };

  # 3. Let miniflux read those files (it takes them as *_FILE env vars).
  systemd.services.miniflux.serviceConfig.SupplementaryGroups = svc.access.oidc.systemd.supplementaryGroups;
}

The framework owns the route, client provisioning, tile, healthcheck, and secrets, and exposes them as derived attributes (svc.publicUrl, svc.access.oidc.id.file, …). You own the service and the few lines that connect the two.

Variations

Same shape (register, run, wire) with small deltas:

  • Secrets in-settings (e.g. Immich): a service that takes a file path in its own config skips the env vars (settings.oauth = { inherit (oidc.provider) issuerUrl; clientId._secret = svc.access.oidc.id.file; clientSecret._secret = svc.access.oidc.secret.file; }) but still needs the supplementary group, since it reads those files as its own user (SupplementaryGroups = svc.access.oidc.systemd.supplementaryGroups).
  • Forward-auth instead of OIDC (no SSO of its own): drop the access.oidc block and set access.model = "forwardAuth". The edge authenticates, and the route waits for a gateway to exist.
  • Native auth (the app logs users in itself, e.g. Jellyfin): access.model = "native" (the default) — register for the route and tile, and let the app do the rest.

Containers

A container registers like any native service. systemdServices resolves to <backend>-<name> on its own, so failure notifications and storage guards attach without you naming the unit.

The one rule is the publish address. Podman and docker DNAT published ports themselves, ahead of the firewall’s input rules, so a port on 0.0.0.0 is reachable from the LAN whatever networking.firewall.allowedTCPPorts says. Publish to loopback and that stops mattering:

virtualisation.oci-containers.containers.papra.ports = [ "127.0.0.1:${toString svc.port}:8080" ];

--network=host is the alternative when the container has to reach other services on the host. It uses the host’s stack directly, so the app’s own bind address is the only thing holding the line. Set that to 127.0.0.1 (BIND_ADDRESS, server_host, whatever the app calls it) and register the same port.

Container networks, inter-container DNS, and volumes stay yours. Rootless publishing also rewrites the client source address, so anything IP-shaped you wire yourself sees the proxy and not the peer.

Resource limits

Throttling or prioritising a service is host-specific tuning, plain systemd with no framework option. Target the unit by name, using a per-service cap or a shared slice. (Some upstream modules pin their own slice, e.g. Immich, which you override with lib.mkForce.)

systemd.services.jellyfin.serviceConfig.CPUQuota = "150%"; # per-service cap

# or a shared budget across services
systemd.slices.media.sliceConfig = {
  CPUQuota = "300%";
  MemoryHigh = "8G";
};
systemd.services.jellyfin.serviceConfig.Slice = "media.slice";
systemd.services.immich-server.serviceConfig.Slice = lib.mkForce "media.slice";

Examples in the wild

bphenriques/dotfiles is the reference deployment: a real host wiring a couple dozen services (*arr stack, Jellyfin, Immich, containers, tasks, backups), always in sync with this flake. It is the exhaustive catalogue this page deliberately isn’t.

Bundled apps

selfhost.apps.<name>.enable runs a bundled app and wires it into whichever concerns you turned on. Default-off, and enabling one brings its framework wiring with it. There is no half-wired mode.

Immich, Jellyfin, FileBrowser Quantum and the *arr stack have their own chapters. Below is what the rest do that their options don’t say. Every option is in the reference.

Radicale

Two routes onto one process. radicale.<domain> serves the web UI behind the forward-auth gateway. dav.<domain> serves sync clients on Radicale’s own htpasswd, because a phone’s calendar app cannot pass a gateway. .well-known/caldav and /.well-known/carddav redirect to the root so clients find the server from the bare hostname.

CouchDB

access.model is native: a replicating client speaks HTTP basic auth and nothing else, so no gateway sits in front. _up stays answerable without credentials for the healthcheck. Everything else requires them.

Gitea

Accounts and the OIDC auth source go in through the gitea admin CLI, which stays stable across versions in a way the API does not. Service-account SSH keys are the exception and use the admin API, since the CLI cannot add them.

Keys are declared, never generated: sshKeys takes public keys, so the private half is made wherever it will be used and stays there. Registration is add-only, so a key dropped from the configuration, or added by hand, stays on the account until removed in the UI. https://<host>/<username>.keys lists what an account actually holds, with no token, which is also how the configure step decides whether it has work to do. Repositories and their permissions are not provisioned: an account that should push to a repository is added to it by hand, once.

ssh.enable is off because it opens a TCP port. Git over HTTPS works without it, using an access token.

RomM

Upstream serves the frontend, the downloads and the emulator’s cross-origin headers from its own nginx vhost, so ingress routes to that vhost and RomM’s API keeps a separate socket behind it. That is why RomM registers two ports where other apps register one.

settings is RomM’s config.yml, linked in from the store, so RomM reports it read-only and its own config editor refuses to save. Netplay is the exception: its TURN password is generated at boot, so the file renders at boot too.

coturn

A STUN/TURN relay for clients that cannot reach each other directly, which today means RomM’s netplay. An app needing it appends a login to logins and reads the generated password back from credential.<login>, so the relay names no service and the password never reaches the store. Relaying is denied to every address allowedPeerRanges does not list, which is what keeps a LAN relay on the LAN. A public relay is that option set to the whole range, spelled out.

Open WebUI

Never behind forward-auth: the gateway answers this app’s XHR with a redirect it cannot follow and the UI reload-loops. With an OIDC provider it federates, otherwise it keeps its own accounts.

Unfree in nixpkgs, so it needs nixpkgs.config.allowUnfree or a predicate admitting open-webui. Every other bundled app is free software.

WireGuard

The way in. Peers are runtime state in /var/lib/wireguard/peers.json, owned by wg-manage, which applies every change to the live interface. Adding or removing someone needs no deploy.

A device’s tier is its address. fullAccessSubnet carves a block out of clientSubnet whose devices reach the LAN; every other address reaches this host on restrictedPeers (TCP 80 and 443 by default, no UDP) and nothing else. The firewall matches those prefixes, so an address is bounded by where it sits rather than by any list being correct, and a peer nobody registered is still restricted.

$ sudo wg-manage add alice-phone
alice-phone is live at 10.100.0.16.
<QR code>

--full-access allocates from the full-access block instead, and errors rather than spilling out of it. --conf prints the config instead of a QR, for someone you cannot hand a screen to; what you send is then a live credential, which the restricted tier is what bounds. Nothing is stored beyond the peer file, so losing the output means adding again, the same answer as a lost phone.

The server mints the keypair, so a client private key does exist here for as long as it takes to render the QR. That is a deliberate trade for one command over an enrolment round-trip, and it holds because the admin already has root on this host. Generate the key on the device and add its public key to the peer file by hand if you want that property back.

wg-manage status is the inventory: address, tier, last handshake, and anything live the file does not list; the peer file also records when each was added. remove <name> cuts a peer and forgets it. apply re-syncs the file onto the interface in both directions, and wireguard-apply-peers runs it whenever the interface appears, so a reboot restores everyone. Anything the three commands do not cover, edit the file and run apply.

Restricted devices reach no DNS either, so dns normally names a resolver the device reaches over its own connection. That resolver has to answer for your domain, since it maps <subdomain>.<domain> to the address the tunnel then carries traffic to. Naming this host instead takes restrictedPeers.udpPorts = [ 53 ].

A restricted client also routes only lanAccess.serverAddress rather than the whole subnet, so someone whose home network overlaps yours keeps their own devices reachable.

The backup hook covers the peer file, not the server private key. Losing the host therefore costs each device one edited field, its peer PublicKey, which the restored file is what makes possible.

deSEC

Keeps hostnames pointed at the current public IP, for reaching a WireGuard server on a connection whose IP moves. WireGuard never answers an unauthenticated packet, so a published home IP offers a scanner nothing. That does not extend to services: publishing an IP and then opening ports to it is a different decision, and one this project does not help you make.

Media automation

The *arr apps (apps.radarr, apps.sonarr, apps.prowlarr, apps.bazarr) follow one rule that shapes the whole design: the framework wires the plumbing, and never configures a source.

The boundary

A media-automation stack has two halves. One is generic infrastructure: ingress, forward-auth, an API key kept out of the store, notifications, a backup of the library list, and the connections between the tools (root folders, download clients). The other is acquisition: which indexers and trackers to search, which release qualities to prefer, which categories to file under.

selfhost-nix owns the first half and ships nothing of the second. The words indexer, tracker, quality profile, custom format appear nowhere in it. Its plumbing is inert on its own: a download client with no indexers behind it fetches nothing. So what and where you acquire is entirely yours, kept in your own (typically private) config, never in the framework.

That makes the apps neutral, general media tooling with legitimate use, and keeps every acquisition decision and its consequences with the operator.

What the app wires

apps.radarr / apps.sonarr register the service (admin-group access, with both the route and forward-auth following the active provider), generate the API key out of the store and set the app to trust the forward-auth identity, add a library-list backup hook, and run an idempotent reconcile that applies only what you declare:

What it reconciles — root folders, download clients, an optional delay profile — is in the options reference. Every one defaults to empty, so enabling an app configures nothing you did not ask for. The framework ships no acquisition config and assumes no protocol: you name a download client’s implementation rather than having torrent, or Transmission, presumed for you.

Because the app is set to trust the identity header, it serves no login of its own, so it is routed only once a forward-auth provider is active. Without one it stays on localhost rather than going up unguarded.

apps.prowlarr is wiring-only. An indexer manager talks to APIs, not files, so it has no root folders or download clients. Its indexer list and app-sync are acquisition and live in your config, reading the apps’ apiKeyFile.

apps.bazarr fetches subtitles for what Radarr/Sonarr already track. The framework pins the API key, the localhost bind and the sonarr/radarr links through nixpkgs’ services.bazarr.settings, which overrides config.yaml on every start, then reconciles languageProfiles (they live in the database) through Bazarr’s settings API. Which providers to search, and with whose credentials, is acquisition: set it in services.bazarr.settings yourself, with credentials as _secret file paths so they never pass through the store. No provider is named anywhere in the framework.

languageProfiles defaults to empty, and Bazarr with no profile downloads nothing — so wanting subtitles at all is an explicit choice you make, not one the framework makes for you.

Failure visibility

A media stack fails quietly: a dead root folder or an unreachable client stops imports, and because nothing is imported, nothing is ever removed from the download client. The apps therefore wire two signals by default — the *arr ntfy connection carries onHealthIssue / onManualInteractionRequired, and exportarr exposes *_system_health_issues and *_queue_total with alert rules on both. Neither is optional, because the failure mode they cover is invisible until you notice missing media.

Note that the generated API keys are 32 characters rather than the framework’s usual 64: exportarr rejects anything outside ^[a-zA-Z0-9]{20,32}$.

What stays yours

  • Indexers / trackers: Prowlarr, from your private config.
  • Quality profiles / custom formats: taste, e.g. a recyclarr unit syncing TRaSH guides. The framework neither bundles nor schedules it (a network-fetching opinion is not plumbing).
  • Download-client ordering: configureAfter = [ "transmission.service" ] (or your usenet client).
  • Where the data lives: root-folder paths and the storage mount, as with any service.

Immich

apps.immich runs Immich and reconciles the accounts plus the external libraries that belong to them. Where photos live stays with the consumer: mediaLocation, transcoding, job concurrency and the machine-learning subsystem are left at their nixpkgs defaults for you to set on services.immich.

Two consumer patterns

Immich stores uploads under its own media location, and can additionally scan directories you already have. The difference is one option:

selfhost.users.alice.services.immich.enable = true;                      # upload-only
selfhost.users.bob.services.immich = {
  enable = true;
  libraries = [ { name = "bob-photos"; importPaths = [ "/mnt/photos/bob" ]; } ];
};

libraries defaults to empty, so an account is upload-only unless you say otherwise, and a deployment that never uses external libraries declares no paths anywhere. Declared paths become writable to the service, since Immich writes sidecars and thumbnails beside the originals it imports.

A library’s name is its reconcile identity. Renaming one creates a second library rather than renaming the first.

Accounts

services.immich.admin defaults to the user’s isAdmin, so fleet-level admin carries into Immich without restating it. quotaBytes and storageLabel are reconciled on every run, so changing a quota moves the existing account.

passwordFile is a bootstrap credential: applied when the account is created and never reconciled, so a password changed in the app survives. An account given one is asked to change it on first login. Accounts without a passwordFile get a random password, which is the right default when sign-in is through OIDC. Without an OIDC provider the service’s access.model composes to native, and each account needs a passwordFile to be reachable.

OIDC only authenticates. autoRegister is off, so an OIDC login lands on an account that selfhost.users already declared, matched by email, and a login with no declared account is refused. That keeps one source of truth for who exists.

The admin@immich.local account is separate from all of these. The reconciler authenticates as it to drive the API, and nothing else uses it.

Nothing is deleted

The reconcile creates and updates, never removes. Dropping a user or a library from your config leaves the Immich side untouched, because deleting either would take photos with it. Accounts created outside the framework are likewise left alone. Removals are a deliberate manual step in the app.

Backups

Uploaded photos live under Immich’s media location, not in your external libraries, so nothing else covers them. They are far too large to copy through a backup hook, so bind them into a target instead, which mounts them read-only with no second copy:

selfhost.backup.targets.<name>.bindings = {
  "/immich/library" = "${config.services.immich.mediaLocation}/library";
  "/immich/upload"  = "${config.services.immich.mediaLocation}/upload";
  "/immich/profile" = "${config.services.immich.mediaLocation}/profile";
};

Skip thumbs/ and encoded-video/, which Immich regenerates. If every account is backed by external libraries, those originals already live in directories you back up separately and there is nothing here to add.

The app registers no backup hook for the database. Immich writes its own nightly dump to <mediaLocation>/backups, so binding that path picks it up if you want the albums, people and favourites that a re-scan cannot rebuild.

Keeping up with Immich

Immich moves API routes across major releases. api-contract.json beside the script declares the request payloads configure.nu sends, and the vm-immich test asserts them against the server’s own /api/spec.json. Immich validates with zod, which strips unknown keys rather than rejecting them, so a renamed field would otherwise stop being applied with nothing to show for it. Bumping nixpkgs and running the test is what tells you the reconciler still holds.

Jellyfin

apps.jellyfin completes the startup wizard unattended, then reconciles the server name, libraries and accounts. Transcoding, storage and everything else stays yours to set on services.jellyfin.

selfhost.apps.jellyfin = {
  enable = true;
  libraries = [
    { name = "Movies"; collectionType = "movies"; locations = [ "/mnt/media/movies" ]; }
  ];
};
selfhost.users.alice.services.jellyfin.enable = true;

Jellyfin refuses to start with under 2 GiB free in its data directory, and aborts with a core dump rather than a readable error. Worth knowing before putting dataDir on a small partition.

Everything is merged, never replaced

branding, encoding, trickplay and a user’s policy are all merged onto whatever Jellyfin currently holds, then written back only if something changed. That matters most for branding: LoginDisclaimer is where an SSO plugin puts its login button, and replacing the whole object would wipe it on every run.

The same applies to library locations, which are reconciled rather than set once. Moving a directory in your config moves the library.

A library’s name is its reconcile identity, so renaming one creates a second library.

Encoding takes exactly one writer

Two mechanisms can own encoding.xml, and mixing them is the trap.

apps.jellyfin.encoding merges onto what Jellyfin currently holds, so fields you do not name keep their value. services.jellyfin.hardwareAcceleration and services.jellyfin.transcoding instead write the file from a template, and on a server that already has one they do nothing until you also set forceEncodingConfig. That template carries 18 elements while a populated encoding.xml runs to over 40, so everything outside it resets to Jellyfin’s default, including TonemappingAlgorithm, PreferSystemNativeHwDecoder and EnableDecodingColorDepth10Vp9, none of which nixpkgs models.

Set both and the file permanently differs from the template, so every restart leaves another encoding.xml.backup-<timestamp> behind. Prefer the nixpkgs options on a fresh install where their defaults are acceptable, and this one on a server whose settings are worth keeping.

Jellyfin ignores properties it does not recognise, so a misspelled field is dropped without complaint. EnableHwEncoding and EnableHwDecoding are the trap worth naming: both are real TrickplayOptions fields, neither exists on EncodingOptions, where the equivalents are EnableHardwareEncoding and HardwareDecodingCodecs.

Forward-auth is off

Jellyfin authenticates its own clients, and native apps cannot pass a forward-auth gateway, so it stays on access.model = "native" rather than following the gateway. Set it to "forwardAuth" only if every client you use is a browser.

Accounts

passwordFile is a bootstrap credential: applied when the account is created and never reconciled, so a password changed in the app survives. Accounts without one get a random password. policy is a freeform passthrough merged into Jellyfin’s UserPolicy, so anything you do not name keeps its current value.

The admin account is separate. The reconciler authenticates as it to drive the API.

Nothing is deleted

The reconcile creates and updates only. Dropping a library or user from your config leaves the Jellyfin side untouched, since removing either is destructive. Removals are a deliberate manual step.

Keeping up with Jellyfin

api-contract.json beside the module declares the request payloads configure.nu sends, and the vm-jellyfin test asserts them against the server’s own /api-docs/openapi.json. Jellyfin deserialises with System.Text.Json, which ignores unknown keys rather than rejecting them, so a renamed field would otherwise stop being applied with nothing to show for it.

POST /Library/VirtualFolders takes its name, collection type and paths as query parameters, so library creation is the one call the contract cannot cover. It fails loudly with a 4xx instead.

Security

The framework wires the cross-cutting security plumbing. The trust anchors and the host stay yours. Know the split.

What the framework does

  • Secrets off the store. runtimeSecrets are generated with openssl into /var/lib/homelab-secrets (root-owned, tight modes), never in the world-readable Nix store. Generation runs sandboxed (ProtectSystem = strict, write scoped to the secrets dir). Each template renders in its own unit, onto tmpfs unless you point its path somewhere else.
  • Durable secret lifecycle. regenerateIfMissing handles disposable random secrets. generateOnce names the data path a key protects, and a key lost while that data survives is left absent and logged, never silently replaced.
  • The edge is the only public surface for HTTP. Services bind 127.0.0.1, and the reverse proxy fronts them. The non-HTTP exceptions each carry their own opt-in and are visible as such: the WireGuard tunnel, Gitea’s SSH server, and the SMB server. Every socket the framework binds is registered, and one assertion checks the whole set for collisions. Each service declares access.model, and the framework gates it accordingly: a per-service OIDC client (group-scoped), or forwardAuth, whose middleware sets the identity headers from the auth response. A service that authenticates nobody itself is not routed at all until a forward-auth provider exists, so the failure mode is an unreachable service rather than an unguarded one.
  • Hardened service units. Every unit the framework defines runs with NoNewPrivileges, PrivateTmp, ProtectHome and the kernel/cgroup protections. A filesystem sandbox (ProtectSystem = strict) is added only where the unit runs as root and writes nothing, or writes solely to paths this project owns — a reconciler writing into an upstream-owned data dir gets none, because a ReadWritePaths that misses a path fails at the write rather than at start. Nothing is added to units nixpkgs owns. The notify token reaches non-root consumers via systemd LoadCredential.
  • WireGuard, not public exposure. That is the way in.

What it will NOT do: your responsibility

  • The root of trust. It generates its own runtime secrets, but any secret you supply (sops/age, …) and the keys that decrypt them are yours to store, back up, and rotate. The framework never sees your master key.
  • Back up generate-once keys. A generateOnce key is safe from silent replacement, but the framework does not back it up. Lose it while its data survives and that data is unrecoverable. Copy these out-of-band. This is the sharpest edge here.
  • Harden the host. SSH, firewall baseline, kernel and account hardening, disk encryption: all the host’s job. The framework hardens its service sandboxes, not your machine.
  • Make the exposure decision. Putting a service on the public internet is your call and your risk, a deliberate out-of-scope choice. Nothing here lightens it.
  • Guard a misconfigured edge. Backends bind localhost so the proxy is the only path in. Keep it that way. Any custom edge or proxy-auth you wire must strip client-supplied identity headers, or they are spoofable. A container published on 0.0.0.0 breaks the invariant quietly, since podman and docker DNAT ahead of the firewall’s input rules. See Containers.

Reading a secret

homelab-secrets is the read-only way in, so you do not have to remember which of the two directories a secret lives in or what it is called. With no argument it lists the apps holding secrets:

$ homelab-secrets ls
APP        SECRETS  STATUS
coturn           2  ok
pocket-id        2  ok
radicale         3  1 drift
sonarr           3  ok

An argument matches both app and name, so ls radicale drills into one app and ls alice finds every secret belonging to one person. cat is the only command that needs root, and it takes the name ls printed:

$ sudo homelab-secrets cat radicale-password-alice

STATUS is worth reading. missing means the file is not there, and the error from cat names the unit that should have generated it. unknown means the file sits under a root-only directory, so its state is unknowable from where you are rather than absent. Running ls as yourself still gives you the whole inventory, and it says how many rows a sudo ls would resolve. drift means the owner or mode on disk is not what was declared, which is what a hand-restored key with the wrong ownership looks like — ls -l shows both values. The generator fixes the mode on its next run, but it is a RemainAfterExit oneshot, so that run is the next reboot unless you restart it yourself.

Handing out a generated account password

Radicale, CouchDB and the SMB server authenticate people directly, because their clients cannot do SSO. The framework generates a password per enabled user instead of asking you for one, root-owned 0400 under runtimeSecretsDir:

$ sudo homelab-secrets cat radicale-password-alice

Read it once and hand it over out of band. Nothing mails it. To roll one, delete the file and restart homelab-runtime-secrets.service.

Key rotation

SecretHow it rotates
OIDC client secretsFramework-managed: oidc-rotate [<client>] (always available) or the opt-in rotation timer. It removes the secret and the provider re-mints it.
Random per-service secrets (regenerateIfMissing)Delete the file, then systemctl restart homelab-runtime-secrets.service. A rebuild alone will not do it: the unit is a RemainAfterExit oneshot, so systemd leaves it alone until its definition changes.
Data-bound keys (generateOnce)Manual and deliberate. Rotating means re-keying the data it protects, so the framework refuses to auto-rotate (that would brick the data). Remove the secret together with the data at the path generateOnce names, then restart the unit above.
Externally-synced secrets (regenerateIfMissing = false)Rotate in your own store. The framework leaves them untouched.

Restore & disaster recovery

There is deliberately no restore command. In a real disaster the host is gone, and any wrapper it built with it, so recovery is a manual runbook you must be able to run from nothing but your backups and your out-of-band keys. Rehearse it before you need it.

What the backups contain. Each selfhost.backup.targets.<name> is an independent rustic repository. On the live host its profile is written to /etc/rustic/<name>.toml (repository + password-file, plus a <name>-secrets.toml for backend credentials). A snapshot holds only what was staged into that target’s tree: your bindings (paths mounted read-only) and each hook’s output under extras/<hook> (e.g. a Gitea repo copy, a DB dump). A hook output is material to replay, not a live service. Restoring a DB dump means importing it, not dropping it onto a running database.

WireGuard peers are runtime state. /var/lib/wireguard/peers.json is the only inventory of who may connect, and it is not in your repo. The app’s backup hook covers it. The server private key is deliberately left out, so restoring the file gets every device its address and public key back while the server presents a new one: each client then needs its [Peer] PublicKey edited, not a re-enrolment.

What is NOT in them. Runtime secrets (/var/lib/homelab-secrets) and OIDC credentials are not backed up unless you explicitly add them as a binding. Two things you must hold out-of-band, or the rest is unrecoverable:

  • your secrets-backend master key (sops/age). Without it none of the secrets you supply decrypt.
  • every generateOnce key (e.g. Pocket-ID’s encryption key). Lose one while its data survives and that data is gone. This is the sharpest edge in the whole system.

Recovery order.

  1. Rebuild the host and restore the secrets backend, putting the sops/age master key back first.
  2. Restore generate-once keys from your out-of-band copy into runtimeSecretsDir before their services start, so they decrypt existing data instead of the guard leaving them absent.
  3. nixos-rebuild switch. Disposable secrets regenerate, and OIDC users and clients re-provision.
  4. Restore data with rustic into each service’s data location, then replay hook outputs (import dumps, drop repos back). List and pull a snapshot with rustic -P <name> snapshots / rustic -P <name> restore <id> --to <dest>. On a bare machine, reconstruct the repo string, password, and backend credentials by hand from your secrets store first.

FileBrowser Quantum

services.filebrowser-quantum runs FileBrowser Quantum and adds the per-user access model it lacks: proxy-auth or OIDC users, each scoped to a directory with its own permissions. It owns the whole service, because nixpkgs ships the package without a module. The base is standalone, usable without the selfhost framework.

Quantum is the maintained successor to the original FileBrowser, which upstream archived on 1 September 2026 at 2.63.23 with no further bug or security fixes.

Two entry points. The standalone base is the nixosModules.filebrowser-quantum output. The selfhost adapter ships inside nixosModules.default as selfhost.apps.filebrowser-quantum.

Access, not storage

Unchanged from the older module. A user’s access is one scope, a path under source.path the host arranged. The module never creates or mounts directories, only authorizes a name at a path, so it stays backend-agnostic. A listed scope with no directory fails startup rather than serving an empty view.

The module owns the source definition rather than passing the list through, because scopes are declared against it and the unlisted scope is a property of the source. Exposing the list raw would split one setting across two places.

Auth is the edge’s job

Same contract as before. A trusted edge authenticates, sets authHeader (default Remote-User), and must strip client-supplied values. The module authorizes the name, never authenticates it.

Quantum auto-creates an authenticated name it does not know, and offers no way to refuse one. Its createUser setting is marked deprecated and always true. Point unlistedScope at an empty directory unless the edge admits only listed users.

Reconciled over the API

Quantum’s CLI creates password accounts only, and refuses to modify an account that logs in another way. Proxy users cannot be seeded offline, so the reconciler drives the running server. It creates, updates and deletes: an account the config no longer declares is removed, which also sweeps up the ones unlisted logins auto-created.

The reconciler authenticates as adminUsername with a password, from adminPasswordFile. That is what makes the account unreachable through the edge: a password login cannot be assumed by a proxy header, so no name the gateway can authenticate becomes admin. An assertion keeps adminUsername out of the declared users. Quantum re-confirms the password on every user mutation, so the reconciler sends it alongside the token it got back.

The reconciler touches only accounts whose loginMethod matches the configured one. The admin’s password account is therefore never a candidate for deletion, even though the config does not declare it.

Config holds no secrets. Quantum reads every one of them from the environment.

WebDAV

Quantum serves WebDAV at /dav, authenticated by Basic auth carrying a JWT in the password field. A request has one Authorization header, so that cannot coexist with an edge that owns it. The module disables WebDAV by default. Turn it on only where nothing else claims the header.

Federated logins

With an OIDC provider active the app federates instead of leaning on the gateway, and proxy auth is switched off entirely, so no header is trusted under that model. The declared access.allowedGroups are also passed to Quantum as userGroups. That is a second check behind the provider’s: Pocket-ID already refuses a user outside the client’s allowed groups, but the OIDC contract does not oblige a provider to, so the service repeats it.

Quantum resolves the issuer at start-up and exits when it cannot reach it. The service therefore depends on the provider being up at boot, not just at login, and recovers through its restart backoff rather than starting degraded.

Options reference

selfhost.apps.bazarr.enable

Whether to enable the first-party Bazarr app (subtitle automation; wiring + reconcile, no providers).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.bazarr.apiKeyFile

Path to Bazarr’s generated API key.

Type: string (read only)

Default: the generated API-key secret path

Declared by:

selfhost.apps.bazarr.dataDir

Bazarr state directory (config.yaml, database, logs).

Type: string

Default:

"/var/lib/bazarr"

Declared by:

selfhost.apps.bazarr.defaultProfile

Profile name applied to newly-added series and movies. Null leaves new media unassigned (nothing is fetched for it).

Type: null or string

Default:

null

Declared by:

selfhost.apps.bazarr.exporterPort

exportarr listen port (localhost) for Bazarr metrics.

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

9712

Declared by:

selfhost.apps.bazarr.languageProfiles

Language profiles to ensure. Which languages to want is taste, so the framework ships none — with no profile Bazarr downloads nothing. The posted list is authoritative: a profile removed here is removed from Bazarr.

Type: list of (submodule)

Default:

[ ]

Example:

[ { name = "English"; languages = [ "en" ]; cutoff = "en"; } ]

Declared by:

selfhost.apps.bazarr.languageProfiles.*.cutoff

Language that ends the search once found; null keeps searching for all of them.

Type: null or string

Default:

null

Declared by:

selfhost.apps.bazarr.languageProfiles.*.languages

Two-letter language codes to search for, in priority order.

Type: list of string

Default:

[ ]

Declared by:

selfhost.apps.bazarr.languageProfiles.*.name

Profile name.

Type: string

Declared by:

selfhost.apps.bazarr.radarr

Radarr to pull the movie library from. Null = Bazarr handles no movies.

Type: null or (submodule)

Default:

null

Declared by:

selfhost.apps.bazarr.radarr.apiKeyFile

Path to Radarr’s API key.

Type: string

Default: the local Radarr’s generated API key when apps.radarr.enable

Declared by:

selfhost.apps.bazarr.radarr.baseUrl

Radarr URL base.

Type: string

Default:

"/"

Declared by:

selfhost.apps.bazarr.radarr.host

Radarr host reachable from Bazarr.

Type: string

Default: the local Radarr’s host when apps.radarr.enable

Declared by:

selfhost.apps.bazarr.radarr.port

Radarr port.

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default: the local Radarr’s port when apps.radarr.enable

Declared by:

selfhost.apps.bazarr.sonarr

Sonarr to pull the series library from. Null = Bazarr handles no TV.

Type: null or (submodule)

Default:

null

Declared by:

selfhost.apps.bazarr.sonarr.apiKeyFile

Path to Sonarr’s API key.

Type: string

Default: the local Sonarr’s generated API key when apps.sonarr.enable

Declared by:

selfhost.apps.bazarr.sonarr.baseUrl

Sonarr URL base.

Type: string

Default:

"/"

Declared by:

selfhost.apps.bazarr.sonarr.host

Sonarr host reachable from Bazarr.

Type: string

Default: the local Sonarr’s host when apps.sonarr.enable

Declared by:

selfhost.apps.bazarr.sonarr.port

Sonarr port.

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default: the local Sonarr’s port when apps.sonarr.enable

Declared by:

selfhost.apps.bentopdf.enable

Whether to enable the first-party BentoPDF app (static, client-side PDF toolkit).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.coturn.enable

Whether to enable the first-party coturn app (STUN/TURN relay).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.coturn.advertisedAddresses

Addresses clients reach this relay on, in the order they should try them. Read by whoever advertises the relay.

Type: list of non-empty string

Default:

[ ]

Example:

[
  "192.168.1.10"
  "10.100.0.1"
]

Declared by:

selfhost.apps.coturn.allowedPeerRanges

Peers relaying is permitted to, in coturn’s allowed-peer-ip syntax. Every other address is denied, so empty relays nowhere and 0.0.0.0-255.255.255.255 is a public relay.

Type: list of non-empty string

Default:

[ ]

Example:

[
  "192.168.1.0-192.168.1.255"
]

Declared by:

selfhost.apps.coturn.credential

Per login: the generated password’s file, and its placeholder for a runtimeTemplates body.

Type: attribute set of attribute set of string (read only)

Default: derived from logins

Declared by:

selfhost.apps.coturn.logins

Long-term credential logins to provision, one generated password each. An app needing the relay appends the login it authenticates with.

Type: list of non-empty string

Default:

[ ]

Example:

[
  "romm"
]

Declared by:

selfhost.apps.coturn.openFirewall

Open the listening port and the relay port range on every interface. Off because a relay serving LAN or VPN clients wants an interface-scoped rule instead.

Type: boolean

Default:

false

Declared by:

selfhost.apps.couchdb.enable

Whether to enable the first-party CouchDB app (document database).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.desec.enable

Whether to enable deSEC dynamic DNS updates.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.desec.domains

Hostnames to keep pointed at the current public IP.

Type: non-empty (list of string)

Example:

[
  "squirrel-plaza.dedyn.io"
]

Declared by:

selfhost.apps.desec.interval

Refresh period (systemd time span); a boot-time update also runs.

Type: string

Default:

"30min"

Declared by:

selfhost.apps.desec.tokenFile

File holding a deSEC API token authorized for domains.

Type: string

Declared by:

selfhost.apps.filebrowser-quantum.enable

Whether to enable the first-party FileBrowser Quantum app (per-user file sharing).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.gitea.enable

Whether to enable the first-party Gitea app (git server with OIDC login).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.gitea.ssh.enable

Whether to enable Gitea’s built-in SSH server for git-over-SSH (off by default — exposes a TCP port; HTTPS git works without it).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.gitea.ssh.openFirewall

Whether to enable opening the SSH port in the firewall (all interfaces); leave off to scope it yourself.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.gitea.ssh.port

Listen port for the built-in SSH server.

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

2222

Declared by:

selfhost.apps.homepage.enable

Whether to enable the first-party homepage dashboard app (gethomepage).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.immich.enable

Whether to enable the first-party Immich app (photo and video library).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.jellyfin.enable

Whether to enable the first-party Jellyfin app (media server).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.jellyfin.branding

Fields merged into Jellyfin’s branding configuration. Merged, not replaced, so a login disclaimer owned by an SSO plugin survives.

Type: attribute set of anything

Default:

{ }

Declared by:

selfhost.apps.jellyfin.defaultPolicy

Policy fields applied to every account, under each user’s own policy. The baseline most deployments want the same for everyone.

Type: attribute set of anything

Default:

{ }

Example:

{
  EnableSubtitleManagement = true;
}

Declared by:

selfhost.apps.jellyfin.encoding

EncodingOptions fields, merged onto what Jellyfin holds and applied through the API after startup, so anything not named here keeps its value.

services.jellyfin.hardwareAcceleration and services.jellyfin.transcoding cover part of the same ground by writing encoding.xml directly. Use one or the other, never both: together the file always differs from the generated template, and each restart leaves another encoding.xml.backup-<timestamp> behind.

Unknown keys are dropped silently, since Jellyfin ignores properties it does not recognise.

Type: attribute set of anything

Default:

{ }

Example:

{
  TonemappingAlgorithm = "bt2390";
}

Declared by:

selfhost.apps.jellyfin.libraries

Libraries to create and keep pointed at their directories.

Type: list of (submodule)

Default:

[ ]

Declared by:

selfhost.apps.jellyfin.libraries.*.collectionType

Jellyfin collection type. Closed set, so a typo fails evaluation rather than the library create.

Type: one of “unknown”, “movies”, “tvshows”, “music”, “musicvideos”, “trailers”, “homevideos”, “boxsets”, “books”, “photos”, “livetv”, “playlists”, “folders”

Example:

"movies"

Declared by:

selfhost.apps.jellyfin.libraries.*.locations

Directories this library scans. Reconciled, so moving a path moves the library.

Type: list of string

Default:

[ ]

Declared by:

selfhost.apps.jellyfin.libraries.*.name

Library name in Jellyfin; also its reconcile identity, so renaming creates a second one.

Type: string

Declared by:

selfhost.apps.jellyfin.libraries.*.options

Fields merged into this library’s Jellyfin LibraryOptions, applied verbatim.

Type: attribute set of anything

Default:

{ }

Example:

{
  EnableRealtimeMonitor = false;
}

Declared by:

selfhost.apps.jellyfin.serverName

Server name Jellyfin reports to clients.

Type: string

Default: the service’s displayName

Declared by:

selfhost.apps.jellyfin.startup.metadataCountryCode

Metadata country code, applied once by the startup wizard.

Type: string

Default:

"US"

Declared by:

selfhost.apps.jellyfin.startup.preferredMetadataLanguage

Preferred metadata language, applied once by the startup wizard.

Type: string

Default:

"en"

Declared by:

selfhost.apps.jellyfin.startup.uiCulture

Interface culture, applied once by the startup wizard.

Type: string

Default:

"en-US"

Declared by:

selfhost.apps.jellyfin.trickplay

Fields merged into Jellyfin’s trickplay options.

Type: attribute set of anything

Default:

{ }

Declared by:

selfhost.apps.miniflux.enable

Whether to enable the first-party Miniflux app (RSS reader with OIDC login).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.open-webui.enable

The first-party Open WebUI app: a chat UI for OpenAI-compatible backends.

Open WebUI is unfree in nixpkgs (its licence restricts removing the branding), so enabling this needs nixpkgs.config.allowUnfree or a predicate admitting open-webui. The framework will not set that for you. Every other bundled app is free software.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.prowlarr.enable

Whether to enable the first-party Prowlarr app (indexer manager; wiring only, no indexers).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.prowlarr.apiKeyFile

Path to Prowlarr’s generated API key, for the consumer indexer-sync reconciler.

Type: string (read only)

Default: the generated API-key secret path

Declared by:

selfhost.apps.prowlarr.exporterPort

exportarr listen port (localhost) for Prowlarr metrics. Each *arr needs its own — exportarr defaults them all to 9708.

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

9711

Declared by:

selfhost.apps.radarr.enable

Whether to enable the first-party Radarr app (media automation; ingress + auth + secrets wired, zero acquisition config).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.radarr.apiKeyFile

Path to Radarr’s generated API key, for consumer reconcilers (e.g. Prowlarr sync, recyclarr).

Type: string (read only)

Default: the generated API-key secret path

Declared by:

selfhost.apps.radarr.configureAfter

Extra units the reconcile must start after and want. Radarr connection-tests a download client on save, so order this after the client’s service (e.g. your torrent/usenet daemon).

Type: list of string

Default:

[ ]

Example:

[
  "transmission.service"
]

Declared by:

selfhost.apps.radarr.delayProfile

Optional default delay profile. Null = leave Radarr’s own default untouched. Carries the protocol preference — acquisition taste, no framework default.

Type: null or (submodule)

Default:

null

Declared by:

selfhost.apps.radarr.delayProfile.enableTorrent

Whether this profile may grab torrent releases.

Type: boolean

Default:

true

Declared by:

selfhost.apps.radarr.delayProfile.enableUsenet

Whether this profile may grab usenet releases.

Type: boolean

Default:

true

Declared by:

selfhost.apps.radarr.delayProfile.bypassIfHighestQuality

Grab immediately, ignoring the delays above, when the release already meets the highest wanted quality.

Type: boolean

Default:

true

Declared by:

selfhost.apps.radarr.delayProfile.preferredProtocol

Protocol preference.

Type: one of “torrent”, “usenet”

Declared by:

selfhost.apps.radarr.delayProfile.torrentDelay

Minutes to hold a torrent release before grabbing it, letting a better one appear first.

Type: signed integer

Default:

0

Declared by:

selfhost.apps.radarr.delayProfile.usenetDelay

Minutes to hold a usenet release before grabbing it, letting a better one appear first.

Type: signed integer

Default:

0

Declared by:

selfhost.apps.radarr.downloadClients

Download clients to register. The framework applies them via the *arr schema; it ships none and assumes no protocol.

Type: list of (submodule)

Default:

[ ]

Declared by:

selfhost.apps.radarr.downloadClients.*.fields

Implementation-specific fields passed through to the client schema (host, port, category, …).

Type: attribute set of anything

Default:

{ }

Declared by:

selfhost.apps.radarr.downloadClients.*.implementation

The *arr download-client implementation (e.g. “Transmission”, “Sabnzbd”). No default — you choose.

Type: string

Declared by:

selfhost.apps.radarr.downloadClients.*.name

Client name in Radarr.

Type: string

Declared by:

selfhost.apps.radarr.downloadClients.*.protocol

Client protocol.

Type: one of “torrent”, “usenet”

Declared by:

selfhost.apps.radarr.exporterPort

exportarr listen port (localhost) for Radarr metrics. Each *arr needs its own — exportarr defaults them all to 9708.

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

9710

Declared by:

selfhost.apps.radarr.mediaManagement.unmonitorDeleted

Unmonitor an item when its file disappears. Off by default: an unmounted or half-restored share is indistinguishable from a deleted library, so one rescan unmonitors everything.

Type: boolean

Default:

false

Declared by:

selfhost.apps.radarr.notifyOnImport

Publish a notification when an item is imported or upgraded. Turn this off when something downstream already tells people content arrived (a request manager, for example) — otherwise one arrival produces two messages describing the same thing. The failure events (health checks, manual interaction required) are always published and are not affected by this.

Type: boolean

Default:

true

Declared by:

selfhost.apps.radarr.rootFolders

Root library folders to ensure. Paths only — storage/protocol-agnostic.

Type: list of (submodule)

Default:

[ ]

Declared by:

selfhost.apps.radarr.rootFolders.*.defaultQualityProfile

Name of a quality profile (consumer/recyclarr-managed) to seed as this folder’s default; null = none.

Type: null or string

Default:

null

Declared by:

selfhost.apps.radarr.rootFolders.*.path

Library path (must exist on disk; typically a selfhost storage mount).

Type: string

Declared by:

selfhost.apps.radicale.enable

Whether to enable the first-party Radicale app (CalDAV/CardDAV server).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.romm.enable

Whether to enable the first-party RomM app (ROM library manager).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.romm.netplay.enable

Point EmulatorJS netplay at the coturn app, with a generated TURN credential and a STUN/TURN pair per advertised address.

Type: boolean

Default:

config.selfhost.apps.coturn.enable

Declared by:

selfhost.apps.romm.settings

RomM’s config.yml: platform bindings, scan exclusions, EmulatorJS. Linked in rather than written, so RomM reports it read-only and its own config editor refuses to save. Empty leaves the file to RomM. Keys are upstream’s, see https://github.com/rommapp/romm/blob/master/examples/config.example.yml.

Type: YAML 1.1 value

Default:

{ }

Example:

{
  exclude = {
    roms = {
      multi_file = {
        names = [
          "artwork"
        ];
      };
    };
  };
  system = {
    platforms = {
      megadrive = "genesis";
    };
  };
}

Declared by:

selfhost.apps.sonarr.enable

Whether to enable the first-party Sonarr app (media automation; ingress + auth + secrets wired, zero acquisition config).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.sonarr.apiKeyFile

Path to Sonarr’s generated API key, for consumer reconcilers (e.g. Prowlarr sync, recyclarr).

Type: string (read only)

Default: the generated API-key secret path

Declared by:

selfhost.apps.sonarr.configureAfter

Extra units the reconcile must start after and want. Sonarr connection-tests a download client on save, so order this after the client’s service (e.g. your torrent/usenet daemon).

Type: list of string

Default:

[ ]

Example:

[
  "transmission.service"
]

Declared by:

selfhost.apps.sonarr.delayProfile

Optional default delay profile. Null = leave Sonarr’s own default untouched. Carries the protocol preference — acquisition taste, no framework default.

Type: null or (submodule)

Default:

null

Declared by:

selfhost.apps.sonarr.delayProfile.enableTorrent

Whether this profile may grab torrent releases.

Type: boolean

Default:

true

Declared by:

selfhost.apps.sonarr.delayProfile.enableUsenet

Whether this profile may grab usenet releases.

Type: boolean

Default:

true

Declared by:

selfhost.apps.sonarr.delayProfile.bypassIfHighestQuality

Grab immediately, ignoring the delays above, when the release already meets the highest wanted quality.

Type: boolean

Default:

true

Declared by:

selfhost.apps.sonarr.delayProfile.preferredProtocol

Protocol preference.

Type: one of “torrent”, “usenet”

Declared by:

selfhost.apps.sonarr.delayProfile.torrentDelay

Minutes to hold a torrent release before grabbing it, letting a better one appear first.

Type: signed integer

Default:

0

Declared by:

selfhost.apps.sonarr.delayProfile.usenetDelay

Minutes to hold a usenet release before grabbing it, letting a better one appear first.

Type: signed integer

Default:

0

Declared by:

selfhost.apps.sonarr.downloadClients

Download clients to register. The framework applies them via the *arr schema; it ships none and assumes no protocol.

Type: list of (submodule)

Default:

[ ]

Declared by:

selfhost.apps.sonarr.downloadClients.*.fields

Implementation-specific fields passed through to the client schema (host, port, category, …).

Type: attribute set of anything

Default:

{ }

Declared by:

selfhost.apps.sonarr.downloadClients.*.implementation

The *arr download-client implementation (e.g. “Transmission”, “Sabnzbd”). No default — you choose.

Type: string

Declared by:

selfhost.apps.sonarr.downloadClients.*.name

Client name in Sonarr.

Type: string

Declared by:

selfhost.apps.sonarr.downloadClients.*.protocol

Client protocol.

Type: one of “torrent”, “usenet”

Declared by:

selfhost.apps.sonarr.exporterPort

exportarr listen port (localhost) for Sonarr metrics. Each *arr needs its own — exportarr defaults them all to 9708.

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

9709

Declared by:

selfhost.apps.sonarr.mediaManagement.unmonitorDeleted

Unmonitor an item when its file disappears. Off by default: an unmounted or half-restored share is indistinguishable from a deleted library, so one rescan unmonitors everything.

Type: boolean

Default:

false

Declared by:

selfhost.apps.sonarr.notifyOnImport

Publish a notification when an item is imported or upgraded. Turn this off when something downstream already tells people content arrived (a request manager, for example) — otherwise one arrival produces two messages describing the same thing. The failure events (health checks, manual interaction required) are always published and are not affected by this.

Type: boolean

Default:

true

Declared by:

selfhost.apps.sonarr.rootFolders

Root library folders to ensure. Paths only — storage/protocol-agnostic.

Type: list of (submodule)

Default:

[ ]

Declared by:

selfhost.apps.sonarr.rootFolders.*.defaultQualityProfile

Name of a quality profile (consumer/recyclarr-managed) to seed as this folder’s default; null = none.

Type: null or string

Default:

null

Declared by:

selfhost.apps.sonarr.rootFolders.*.path

Library path (must exist on disk; typically a selfhost storage mount).

Type: string

Declared by:

selfhost.apps.transmission.enable

Whether to enable the first-party Transmission app (torrent client).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.wireguard.enable

Whether to enable WireGuard VPN server (interface, server keys, and runtime peer provisioning via wg-manage).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.wireguard.address

Server address with CIDR (e.g. 10.100.0.1/24).

Type: string

Declared by:

selfhost.apps.wireguard.clientSubnet

Client address subnet (e.g. 10.100.0.0/24).

Type: string

Declared by:

selfhost.apps.wireguard.dns

DNS server pushed to clients. A resolver the device reaches over its own connection works for every device, and is what makes <subdomain>.<domain> resolve before the tunnel carries the request.

Naming this host instead takes restrictedPeers.udpPorts = [ 53 ], since a device outside fullAccessSubnet reaches only the ports listed there.

Type: string

Example:

"1.1.1.1"

Declared by:

selfhost.apps.wireguard.endpoint

Public endpoint host/IP that clients dial.

Type: string

Declared by:

selfhost.apps.wireguard.exporterPort

Prometheus wireguard-exporter listen port (localhost).

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

9586

Declared by:

selfhost.apps.wireguard.fullAccessSubnet

Block within clientSubnet whose devices skip restrictedPeers and, with lanAccess.enable, reach the LAN. Every other client address is restricted, so a device’s tier is its address. Null grants full access to nobody.

Type: null or string

Default:

null

Example:

"10.100.0.0/28"

Declared by:

selfhost.apps.wireguard.interface

WireGuard interface name.

Type: string

Default:

"wg0"

Declared by:

selfhost.apps.wireguard.lanAccess.enable

Whether to enable opt-in nftables forwarding/NAT so clients reach the LAN (else clients reach only the server).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.wireguard.lanAccess.masquerade

Whether to enable srcnat masquerade of client traffic into the LAN (enable only if the LAN lacks routes back to the client subnet).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.wireguard.lanAccess.serverAddress

This host’s own address on subnet. Restricted devices route just this instead of the whole subnet, so a client whose home network overlaps it keeps its local devices. Null leaves them routing the whole subnet.

Type: null or string

Default:

null

Example:

"192.168.1.10"

Declared by:

selfhost.apps.wireguard.lanAccess.subnet

LAN subnet full-access clients may reach; added to their AllowedIPs and used as the masquerade destination. Required when lanAccess.enable.

Type: string

Declared by:

selfhost.apps.wireguard.lanAccess.wakeOnLan

Whether to enable forwarding Wake-on-LAN magic packets (UDP 7 and 9) from full-access clients to the LAN broadcast address; every other directed broadcast stays in the tunnel.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.apps.wireguard.listenPort

WireGuard UDP listen port (opened in the firewall).

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

51820

Declared by:

selfhost.apps.wireguard.openFirewall

Open the WireGuard listen UDP port in the firewall.

Type: boolean

Default:

true

Declared by:

selfhost.apps.wireguard.restrictedPeers.tcpPorts

TCP ports on this host a device outside fullAccessSubnet may reach. Everything else another service opened on the tunnel interface is dropped for it. Empty allows none.

Type: list of 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

[
  80
  443
]

Declared by:

selfhost.apps.wireguard.restrictedPeers.udpPorts

UDP ports on this host a device outside fullAccessSubnet may reach. Empty allows none, which is why dns normally names a resolver these devices reach without the server. Open 53 here instead if the resolver is this host.

Type: list of 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

[ ]

Example:

[
  53
]

Declared by:

selfhost.auth.forwardAuth.active

Whether a forward-auth provider is active. Compose service defaults against this.

Type: boolean (read only)

Default: true once a provider defines url

Declared by:

selfhost.auth.forwardAuth.path

Verify path appended to url for the ingress forward-auth middleware (e.g. /api/auth/traefik); set by the active provider.

Type: string

Default:

""

Declared by:

selfhost.auth.forwardAuth.tinyauth.enable

Whether to enable tinyauth forward-auth gateway (federates to the selfhost OIDC provider).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.auth.forwardAuth.url

Base URL of the forward-auth endpoint, set by the active provider and consumed by the ingress provider. Left undefined until one is, which is what active reads.

Type: string

Declared by:

selfhost.auth.oidc.active

Whether an OIDC provider is active. Compose service defaults against this.

Type: boolean (read only)

Default: true once a provider defines provider.issuerUrl

Declared by:

selfhost.auth.oidc.clients

Derived OIDC client configs keyed by service name (read-only)

Type: attribute set of raw value (read only)

Default:

{ }

Declared by:

selfhost.auth.oidc.credentials.dir

Base directory holding each client’s id and secret. Persistent, not tmpfs: these have no source to re-derive from, so a tmpfs would mint new ones every boot.

Type: string (read only)

Default:

"/var/lib/homelab-oidc"

Declared by:

selfhost.auth.oidc.pocket-id.enable

Whether to enable Pocket-ID OIDC implementation.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.auth.oidc.provider.discoveryUrl

OIDC discovery document URL (derived from issuerUrl); for consumers that need the full well-known URL rather than the bare issuer.

Type: string (read only)

Default: <issuerUrl>/.well-known/openid-configuration

Declared by:

selfhost.auth.oidc.provider.displayName

Display name of the OIDC provider (shown in UI)

Type: string

Declared by:

selfhost.auth.oidc.provider.internalName

Internal name for URLs and identifiers

Type: string

Declared by:

selfhost.auth.oidc.provider.issuerUrl

OIDC issuer URL (e.g. https://auth.example.com). Left undefined until a provider sets it, which is what active reads.

Type: string

Declared by:

selfhost.auth.oidc.provisionConfig

Provisioning config derived from OIDC-enabled users and services (read-only)

Type: raw value (read only)

Default:

{
  groups = [
    {
      name = "admin";
    }
    {
      name = "guests";
    }
    {
      name = "users";
    }
  ];
  users = [ ];
}

Declared by:

selfhost.auth.oidc.rotation.enable

Whether to enable a timer that rotates all OIDC client secrets on a schedule.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.auth.oidc.rotation.schedule

systemd OnCalendar expression for the rotation timer (default: weekly, Sunday 03:00).

Type: string

Default:

"Sun *-*-* 03:00:00"

Example:

"monthly"

Declared by:

selfhost.auth.oidc.systemd.baseProvisionUnit

Systemd unit for base OIDC provisioning (users/groups)

Type: null or string

Default:

null

Declared by:

selfhost.auth.oidc.systemd.clientProvisionUnitPrefix

Prefix for per-client provisioning unit names (provider sets this; e.g. ‘<provider>-provision-client-’ yields ‘<provider>-provision-client-<name>.service’).

Type: null or string

Default:

null

Declared by:

selfhost.backup.package

The rustic-manage package to use (shared by all targets).

Type: package

Default:

pkgs.selfhost.rustic-manage

Declared by:

selfhost.backup.targets

Backup destinations. Each is an independent rustic pipeline with its own content, repository, retention, and schedule.

Snapshots are scoped by host and target name, so several hosts may share one repository without applying each other’s retention. Renaming a host leaves its existing snapshots outside every scope (rustic cannot rewrite a snapshot’s hostname): they are never forgotten, and have to be removed by id.

Type: attribute set of (submodule)

Default:

{ }

Declared by:

selfhost.backup.targets.<name>.enable

Whether this target runs. Off keeps the configuration but drops the units, for pausing one destination without deleting how it is set up.

Type: boolean

Default:

true

Declared by:

selfhost.backup.targets.<name>.backendCredentialsFile

Storage backend access credentials as a rustic secrets TOML ([repository.options]); symlinked to /etc/rustic/<name>-secrets.toml. Null for local-path repositories (no backend auth).

Type: null or string

Default:

null

Declared by:

selfhost.backup.targets.<name>.backupSchedule

systemd OnCalendar schedule for the backup timer.

Type: string

Default:

"*-*-* 03:00:00"

Declared by:

selfhost.backup.targets.<name>.bindings

Virtual backup path (key, must start with ‘/’) -> source path (value), mounted read-only into this target’s tree.

Type: attribute set of string

Default:

{ }

Example:

{
  "/nas/notes" = "/mnt/nas/notes";
}

Declared by:

selfhost.backup.targets.<name>.globs

rustic include/exclude globs (‘!’ = exclude). Default excludes common NAS/sync/OS metadata.

A bare name matches every directory carrying it. One specific directory takes its absolute path inside this target’s staging tree, /var/lib/homelab-backup/<target>/src plus the bindings key it sits under. Dropping a whole source is better done by leaving it out of bindings, and a .nobackup file excludes its directory whoever puts it there.

Type: list of string

Default:

[
  "!@eaDir"
  "!.stfolder"
  "!.stversions"
  "!.Trash*"
  "!@Recycle"
  "!$RECYCLE.BIN"
  "!.DS_Store"
  "!Thumbs.db"
  "!#snapshot"
  "!#recycle"
  "!.zfs"
]

Declared by:

selfhost.backup.targets.<name>.hooks

Standalone pre-backup hooks not tied to a registry service (e.g. GitHub), run for this target.

Type: attribute set of (submodule)

Default:

{ }

Declared by:

selfhost.backup.targets.<name>.hooks.<name>.package

Package providing a backup script. OUTPUT_DIR points to a fresh, empty directory for the hook’s output.

Type: package

Declared by:

selfhost.backup.targets.<name>.hooks.<name>.after

Systemd units this hook must run after (e.g. the service whose data it snapshots).

Type: list of string

Default:

[ ]

Declared by:

selfhost.backup.targets.<name>.passwordFile

rustic repository encryption password file.

Type: string

Declared by:

selfhost.backup.targets.<name>.prune

Whether this target’s forget also prunes, reclaiming space from unreferenced packs.

rustic takes no repository lock, and its delayed-delete only makes prune safe against a concurrent backup, not against a concurrent prune: prune repacks by reading packs the other run may be deleting. So in a repository shared by several hosts, exactly one may prune. The others still forget, and the pruning host reclaims their space too, because reachability is evaluated across the whole repository.

Type: boolean

Default:

true

Declared by:

selfhost.backup.targets.<name>.repository

rustic repository string (e.g. ‘opendal:b2’).

Type: string

Declared by:

selfhost.backup.targets.<name>.retention

Snapshot retention policy (rustic forget keep-within-*).

Type: submodule

Declared by:

selfhost.backup.targets.<name>.retention.daily

How long to keep daily snapshots (rustic keep-within-daily; e.g. 7 days).

Type: string

Declared by:

selfhost.backup.targets.<name>.retention.monthly

How long to keep monthly snapshots (e.g. 1 year).

Type: string

Declared by:

selfhost.backup.targets.<name>.retention.weekly

How long to keep weekly snapshots (e.g. 1 month).

Type: string

Declared by:

selfhost.backup.targets.<name>.retention.yearly

How long to keep yearly snapshots (e.g. 2 years).

Type: string

Declared by:

selfhost.backup.targets.<name>.services

Registry services whose backup hook to run for this target (must declare services.<name>.backup.package).

Type: list of impossible (empty enum)

Default:

[ ]

Declared by:

selfhost.backup.targets.<name>.verifySchedule

systemd OnCalendar schedule for the verification timer.

Type: string

Default:

"Sun *-*-* 05:00:00"

Declared by:

selfhost.dashboards.generatedTiles

Service/external tiles keyed by integrations.homepage.group (read-only). The bundled apps.homepage renders these; otherwise read them into a dashboard you own and decide tabs/layout.

Type: attribute set of list of anything (read only)

Default: derived from services and externals that opt into integrations.homepage

Declared by:

selfhost.external

External services not managed by this host (shown on homepage dashboard via integrations.homepage)

Type: attribute set of (submodule)

Default:

{ }

Declared by:

selfhost.external.<name>.displayName

Human-readable name (defaults to attribute name)

Type: string

Default:

"‹name›"

Declared by:

selfhost.external.<name>.integrations.homepage

Homepage dashboard integration

Type: submodule

Default:

{ }

Declared by:

selfhost.external.<name>.integrations.homepage.enable

Whether to enable homepage entry for this service.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.external.<name>.integrations.homepage.group

Free-form tile group this service belongs to; you map groups to tabs/layout in your dashboard.

Type: string

Default:

"Services"

Declared by:

selfhost.external.<name>.integrations.homepage.icon

Icon name from dashboard-icons (e.g. ‘miniflux.svg’)

Type: null or string

Default:

"‹name›.svg"

Declared by:

selfhost.external.<name>.integrations.homepage.settings

Extra homepage tile settings merged into the generated entry (e.g. a widget).

Type: attribute set

Default:

{ }

Declared by:

selfhost.external.<name>.meta.category

Canonical grouping key; a consumer surface (e.g. a landing page) groups services by this.

Type: null or string

Default:

null

Declared by:

selfhost.external.<name>.meta.description

Short description (nixpkgs meta.description); shown under the name on a dashboard tile. Empty renders no subtitle.

Type: string

Default:

""

Declared by:

selfhost.external.<name>.meta.homepage

Upstream project homepage (nixpkgs meta.homepage).

Type: null or string

Default:

null

Declared by:

selfhost.external.<name>.name

Registry identifier (defaults to attribute name)

Type: string

Default:

"‹name›"

Declared by:

selfhost.external.<name>.url

Direct URL to the external service

Type: string

Declared by:

selfhost.groups.admin

Name of the admin group

Type: string

Default:

"admin"

Declared by:

selfhost.groups.guests

Name of the guests group

Type: string

Default:

"guests"

Declared by:

selfhost.groups.users

Name of the users group

Type: string

Default:

"users"

Declared by:

selfhost.ingress.acme.dns01.credentialsEnvFile

Path to an env file with the DNS-01 provider’s credentials (e.g. CF_DNS_API_TOKEN). Provided by the host, e.g. via sops-nix.

Type: string

Declared by:

selfhost.ingress.acme.dns01.provider

DNS-01 challenge provider name for the ACME client.

Type: string

Example:

"cloudflare"

Declared by:

selfhost.ingress.acme.email

ACME account email for certificate registration

Type: string

Declared by:

selfhost.ingress.allowedInterfaces

Interfaces to scope the opened ports to. Empty opens them on all interfaces. To open nothing at all, set openFirewall = false rather than clearing this.

Type: list of string

Default:

[ ]

Example:

[
  "eth0"
]

Declared by:

selfhost.ingress.domain

Base domain for publicly routed services (e.g. ‘home.example.com’). Required once any service enables ingress; a host that routes nothing may leave it null.

Type: null or string

Default:

null

Declared by:

selfhost.ingress.hosts

Public hostnames this host’s ingress serves, for consumers that resolve or publish them.

Type: list of string (read only)

Default: every ingress-enabled service’s publicHost

Declared by:

selfhost.ingress.openFirewall

Whether the ingress implementation opens 80/443. Enabling ingress means wanting it reachable, so this follows. Turn it off to place the rules yourself.

Type: boolean

Default:

true

Declared by:

selfhost.ingress.traefik.enable

Whether to enable Traefik reverse-proxy ingress implementation.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.ingress.traefik.metricsPort

Port for Traefik’s Prometheus metrics endpoint (localhost only)

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

8082

Declared by:

selfhost.internal.listeningPorts.*.host

Listen address.

Type: string

Default:

"127.0.0.1"

Declared by:

selfhost.internal.listeningPorts.*.name

Owner identifier, shown in collision messages.

Type: string

Declared by:

selfhost.internal.listeningPorts.*.port

Listen port.

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Declared by:

selfhost.internal.listeningPorts.*.protocol

Transport the socket binds; only sockets sharing one can collide.

Type: one of “tcp”, “udp”

Default:

"tcp"

Declared by:

selfhost.mail.active

Whether outbound mail is configured. Compose against this: a consumer that never sets selfhost.mail gets the features that need no SMTP, and nothing asks for a value nobody supplied.

Type: boolean (read only)

Default: true once host is set

Declared by:

selfhost.mail.from

Sender email address

Type: string

Declared by:

selfhost.mail.host

SMTP server hostname. Setting it is what makes active true, so the rest of this block is expected alongside it.

Type: string

Declared by:

selfhost.mail.passwordFile

Path to file containing the SMTP password (typically a sops secret path). Each consumer reads it as its own service user, so own the file by that user — today Pocket-ID’s, the only one that sends mail.

Type: string

Declared by:

selfhost.mail.port

SMTP server port

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

587

Declared by:

selfhost.mail.tls

TLS mode for SMTP connection

Type: one of “none”, “starttls”, “tls”

Default:

"starttls"

Declared by:

selfhost.mail.user

SMTP authentication username

Type: string

Declared by:

selfhost.monitoring.enable

Prometheus monitoring (metrics, healthchecks, alert rules).

Prometheus and Alertmanager authenticate nobody, so they are routed only once a forward-auth provider is active — without one they stay on localhost and no <name>.<domain> route is created. This is deliberate: publishing them ungated would expose every metric and let anyone silence alerts. Set selfhost.services.prometheus.ingress.enable (or .alertmanager.) to override, at which point authenticating them is yours to arrange.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.monitoring.alertmanager.enable

Whether to enable Alertmanager alert delivery (routes fired alerts to notify).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.monitoring.alertmanager.port

Alertmanager listen port (localhost, behind ingress).

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

9093

Declared by:

selfhost.monitoring.blackboxPort

Blackbox-exporter listen port (localhost) for service healthchecks.

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

9116

Declared by:

selfhost.monitoring.prometheusPort

Prometheus listen port (localhost, behind ingress).

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Default:

9090

Declared by:

selfhost.monitoring.retentionSize

Prometheus metrics retention size cap (the effective bound).

Type: string

Default:

"5GB"

Declared by:

selfhost.monitoring.retentionTime

Prometheus metrics retention time.

Type: string

Default:

"365d"

Declared by:

selfhost.monitoring.scopes

Monitoring scopes: infrastructure, hardware, and other non-service metric sources

Type: attribute set of (submodule)

Default:

{ }

Declared by:

selfhost.monitoring.scopes.<name>.enable

Whether to enable this monitoring scope.

Type: boolean

Default:

true

Example:

true

Declared by:

selfhost.monitoring.scopes.<name>.exporters

Prometheus exporter definitions merged into services.prometheus.exporters

Type: attribute set of anything

Default:

{ }

Declared by:

selfhost.monitoring.scopes.<name>.rules

Prometheus alert rule groups

Type: list of (attribute set)

Default:

[ ]

Declared by:

selfhost.monitoring.scopes.<name>.scrapeConfigs

Prometheus scrape configurations

Type: list of (attribute set)

Default:

[ ]

Declared by:

selfhost.monitoring.scopes.<name>.systemdOverrides

Systemd service overrides for monitoring-related units

Type: attribute set of anything

Default:

{ }

Declared by:

selfhost.monitoring.scrapeInterval

Global Prometheus scrape and evaluation interval.

Type: string

Default:

"60s"

Declared by:

selfhost.notify.package

send-notification implementation. Contract: send-notification --topic <t> --message <m> [--title <T>] [--priority <p>] [--tags <x>], reading NOTIFY_URL and NOTIFY_TOKEN_FILE from the env.

Type: package

Default:

pkgs.selfhost.send-notification

Declared by:

selfhost.notify.active

Whether a notify provider is active. Compose service defaults against this.

Type: boolean (read only)

Default: true once a provider defines url

Declared by:

selfhost.notify.defaultTopic

Topic a publisher falls back to when it names none. This is the consumer’s own default, not the framework’s, so it creates no topic nobody asked for; what it changes is whether forgetting to route something means silence or means hearing about it on one known topic.

Type: null or impossible (empty enum)

Default:

null

Declared by:

selfhost.notify.ntfy.enable

Whether to enable ntfy notification implementation (server + provisioning).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.notify.ntfy.remotePublishers

Publishers that run on another host. Their user, ACL and token are provisioned here; there is no secret transport between hosts, so copying the token into the other host’s own secrets stays manual.

Type: attribute set of (submodule)

Default:

{ }

Declared by:

selfhost.notify.ntfy.remotePublishers.<name>.tokenFile

Where the provisioned token lands, root-owned 0400.

Type: string

Default:

"/var/lib/homelab-secrets/notify-publishers/‹name›"

Declared by:

selfhost.notify.ntfy.remotePublishers.<name>.topics

Topics this publisher may write to. A remote task that routes failures separately needs both its topic and its failureTopic here, since this host cannot read the other one’s config.

Type: non-empty (list of impossible (empty enum))

Declared by:

selfhost.notify.provisioningUnit

Systemd unit of the active provider that provisions publisher tokens; consumers that read a token via LoadCredential order after it. null = no provider, or a provider with no provisioning step.

Type: null or string

Default:

null

Declared by:

selfhost.notify.topics

Notification topics and their visibility. Naming them is the consumer’s: no framework subsystem registers or defaults one, so a topic cannot outlive the publisher a default pointed at.

Type: attribute set of (submodule)

Default:

{ }

Declared by:

selfhost.notify.topics.<name>.public

Whether the topic can be read without authentication (grants everyone ro). Publishing always needs a per-service token, so this decides who can see the messages, not who can send them.

Type: boolean

Default:

false

Declared by:

selfhost.notify.url

Base URL of the notification endpoint, set by the active notify provider and consumed by send-notification (NOTIFY_URL). Left undefined until one is, which is what active reads.

Type: string

Declared by:

selfhost.oidcPlaceholder

Opaque placeholder pair per OIDC client.

Type: attribute set of (submodule) (read only)

Default:

{ }

Declared by:

selfhost.oidcPlaceholder.<name>.id

Placeholder standing in for this client’s OIDC client ID.

Type: string

Declared by:

selfhost.oidcPlaceholder.<name>.secret

Placeholder standing in for this client’s OIDC client secret.

Type: string

Declared by:

selfhost.runtimePlaceholder

Opaque placeholder string per declared runtime secret.

Type: attribute set of string (read only)

Default:

{ }

Declared by:

selfhost.runtimeSecrets

Runtime-generated secret files (one-shot openssl rand).

Type: attribute set of (submodule)

Default:

{ }

Declared by:

selfhost.runtimeSecrets.<name>.bytes

Random bytes (hex-encoded; file is 2x chars).

Type: signed integer

Default:

32

Declared by:

selfhost.runtimeSecrets.<name>.generateOnce

Path to the data this secret protects (e.g. a service’s data dir), which makes it generate-once: created if absent, then never silently replaced (this supersedes regenerateIfMissing). For data-bound secrets such as an encryption key, where a fresh value would orphan what it opened.

The path is the guard. While it exists and is non-empty, a missing secret is left absent and logged — restore it rather than letting a rebuild manufacture a new one. A wiped host with no data to orphan generates cleanly. To rotate deliberately, remove the secret together with the data. null means this is an ordinary regenerable secret.

The unit that creates this path must be listed in restartUnits, which is what orders it after the generator. Without that ordering it can populate the path first, and the guard then suppresses the very first generation, permanently and silently.

Type: null or string

Default:

null

Example:

"/var/lib/pocket-id"

Declared by:

selfhost.runtimeSecrets.<name>.group

Unix group; defaults to owner’s primary group.

Type: null or string

Default:

null

Declared by:

selfhost.runtimeSecrets.<name>.mode

File mode (octal) of the secret file.

Type: string

Default:

"0400"

Declared by:

selfhost.runtimeSecrets.<name>.owner

Unix owner of the file.

Type: string

Default:

"root"

Declared by:

selfhost.runtimeSecrets.<name>.path

Where this secret is generated; read it to feed a consumer that wants a file path.

Type: string (read only)

Default: /var/lib/homelab-secrets/<name>

Declared by:

selfhost.runtimeSecrets.<name>.regenerateIfMissing

Generate a new random value if the file is missing. When false (externally-synced secrets), the file is left absent and logged rather than aborting secret generation; consumers fail until it is restored.

Type: boolean

Default:

true

Declared by:

selfhost.runtimeSecrets.<name>.restartUnits

Units consuming this secret; wired requires+after on the generator (ordering only; values are persistent).

Type: list of string

Default:

[ ]

Declared by:

selfhost.runtimeSecretsDir

Persistent directory containing runtime-generated secret files. Include in backups.

Type: string (read only)

Default:

"/var/lib/homelab-secrets"

Declared by:

selfhost.runtimeTemplates

Templates rendered from runtime secrets and OIDC credentials.

Type: attribute set of (submodule)

Default:

{ }

Declared by:

selfhost.runtimeTemplates.<name>.content

Template body; reference secrets via runtimePlaceholder.<name> and OIDC creds via oidcPlaceholder.<client>.{id,secret}.

Type: strings concatenated with “\n”

Declared by:

selfhost.runtimeTemplates.<name>.group

Unix group; defaults to owner’s primary group.

Type: null or string

Default:

null

Declared by:

selfhost.runtimeTemplates.<name>.mode

File mode (octal) of the rendered file.

Type: string

Default:

"0400"

Declared by:

selfhost.runtimeTemplates.<name>.owner

Unix owner of the rendered file.

Type: string

Default:

"root"

Declared by:

selfhost.runtimeTemplates.<name>.path

Rendered output path (tmpfs; regenerated each boot).

Type: string

Default:

"/run/homelab-secrets/templates/‹name›"

Declared by:

selfhost.runtimeTemplates.<name>.restartUnits

Units restarted when the template body changes between deploys.

Type: list of string

Default:

[ ]

Declared by:

selfhost.serviceAccounts

Non-human principals, keyed by account name. Per-app config mirrors the registry at serviceAccounts.<name>.services.<app>, the same shape as users.<name>.services.<app>.

unixAccount.enable additionally creates a POSIX account and its primary group on the host that declares it, for accounts that need a Unix identity (an SMB principal owning files, say). It is spelled the same on users.<name>, which gets a person-shaped account instead of a system one. Accounts that only exist inside an application leave it off.

Type: attribute set of (submodule)

Default:

{ }

Declared by:

selfhost.serviceAccounts.<name>.description

What this account is for. Used as the gecos field when a POSIX user is created.

Type: string

Default:

""

Declared by:

selfhost.serviceAccounts.<name>.services.gitea.enable

Whether to enable a non-UI Gitea account for this service account.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.serviceAccounts.<name>.services.gitea.sshKeys

SSH keys for git-over-SSH; registered via the admin API on account creation.

Type: list of (submodule)

Default:

[ ]

Declared by:

selfhost.serviceAccounts.<name>.services.gitea.sshKeys.*.key

Public key in authorized_keys format.

Type: string

Declared by:

selfhost.serviceAccounts.<name>.services.gitea.sshKeys.*.readOnly

Register as a read-only (deploy) key.

Type: boolean

Default:

false

Declared by:

selfhost.serviceAccounts.<name>.storage.smb.enable

Whether to enable an SMB account for this principal on a host serving selfhost.storage.shares.smb.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.serviceAccounts.<name>.storage.smb.passwordFile

File holding this principal’s SMB password, read at activation through a credential and never placed in the store. Generated by default, like Radicale’s and CouchDB’s: a person types it into a device once, so nobody needs to pick it. Set it instead when the same value has to exist somewhere else too, as for a machine principal whose client reads it from your own secret store: generating here would leave you hand-copying it there.

Type: string

Default: a password generated for this principal under runtimeSecretsDir

Declared by:

selfhost.serviceAccounts.<name>.unixAccount.enable

Whether to enable a POSIX account and matching primary group for this account on the host that declares it, so a service can resolve a session to a Unix identity.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.serviceAccounts.<name>.unixAccount.gid

As in users.groups.<name>.gid. Same reasoning as uid.

Type: null or signed integer

Default:

null

Declared by:

selfhost.serviceAccounts.<name>.unixAccount.uid

As in users.users.<name>.uid. Null picks a free one on activation; pin it where this account owns data that outlives the root filesystem, since the allocation is recorded only on root.

Type: null or signed integer

Default:

null

Declared by:

selfhost.services

Registry of selfhost services: routing, metadata, and integrations (HTTP ingress optional via ingress.enable).

Type: attribute set of (submodule)

Default:

{ }

Declared by:

selfhost.services.<name>.access.allowedGroups

Groups authorized to access this service (canonical groups or any a user is in). Empty means unrestricted (any authenticated user).

Type: list of (one of “admin”, “guests”, “users”)

Default:

[ ]

Declared by:

selfhost.services.<name>.access.model

Who authenticates this service’s users.

  • oidc: the service is its own client of the framework’s OIDC provider (configure it under access.oidc).
  • forwardAuth: the gateway authenticates before the service sees the request. The service has no login of its own, so it is routed only while a forward-auth provider is active.
  • native: the service authenticates its own users by a mechanism the framework does not manage.
  • open: nobody authenticates. Anyone who can reach the route can use it. This says the service has no lock, not that it faces the internet — nothing is exposed beyond ingress.allowedInterfaces under any model.

Only oidc and forwardAuth let the framework enforce access.allowedGroups.

Type: one of “oidc”, “forwardAuth”, “native”, “open”

Default:

"native"

Declared by:

selfhost.services.<name>.access.oidc

OIDC client configuration, provisioned when access.model = "oidc".

Type: submodule

Default:

{ }

Declared by:

selfhost.services.<name>.access.oidc.callbackURLs

Callback URLs for the OIDC client

Type: list of string

Default: [ <publicUrl>/oauth2/oidc/callback ]

Declared by:

selfhost.services.<name>.access.oidc.group

Group name for this client’s credentials

Type: string (read only)

Default:

"homelab-oidc-‹name›"

Declared by:

selfhost.services.<name>.access.oidc.id.file

Path to the file containing the client ID

Type: string (read only)

Default:

"/var/lib/homelab-oidc/‹name›/id"

Declared by:

selfhost.services.<name>.access.oidc.id.placeholder

Stable opaque stand-in for the client ID, for a service whose config the framework does not render. Embed it, then substitute it yourself from id.file (a preStart replace-secret, say). Distinct from selfhost.oidcPlaceholder.<client>.id, which is the scheme runtimeTemplates substitutes for you and is what an env-file service wants.

Type: string (read only)

Default:

"@HOMELAB_OIDC_‹name›_ID@"

Declared by:

selfhost.services.<name>.access.oidc.name

Display name of the OIDC client in the provider

Type: string

Default:

"‹name›"

Declared by:

selfhost.services.<name>.access.oidc.pkce

Enable PKCE for this client

Type: boolean

Default:

false

Declared by:

selfhost.services.<name>.access.oidc.secret.file

Path to the file containing the client secret

Type: string (read only)

Default:

"/var/lib/homelab-oidc/‹name›/secret"

Declared by:

selfhost.services.<name>.access.oidc.secret.placeholder

Stable opaque stand-in for the client secret, substituted by the consumer. See id.placeholder.

Type: string (read only)

Default:

"@HOMELAB_OIDC_‹name›_SECRET@"

Declared by:

selfhost.services.<name>.access.oidc.systemd.dependentServices

Systemd services needing this client’s credentials; auto-wired requires/after/partOf.

Type: list of string

Default:

[ ]

Declared by:

selfhost.services.<name>.access.oidc.systemd.loadCredentials

Ready-to-use LoadCredential entries for systemd services

Type: list of string (read only)

Default:

[
  "oidc-id:/var/lib/homelab-oidc/‹name›/id"
  "oidc-secret:/var/lib/homelab-oidc/‹name›/secret"
]

Declared by:

selfhost.services.<name>.access.oidc.systemd.supplementaryGroups

Groups to add for direct credential file access

Type: list of string (read only)

Default:

[
  "homelab-oidc-‹name›"
]

Declared by:

selfhost.services.<name>.backend

Another registry entry whose backend this one routes to, instead of owning one. Its host/port/scheme become this entry’s, and only the owner is counted for port collisions.

For a second hostname onto the same process — one that wants its own access.model, middlewares or healthcheck. Null means this entry owns its backend.

Type: null or string

Default:

null

Example:

"radicale"

Declared by:

selfhost.services.<name>.backup.package

Package providing backup script. Use writeShellApplication with runtimeInputs for dependencies. OUTPUT_DIR is provided as an environment variable pointing to a fresh, empty directory for the hook’s output.

Type: null or package

Default:

null

Declared by:

selfhost.services.<name>.backup.after

Systemd services this backup hook requires and orders after.

Type: list of string

Default:

[ ]

Declared by:

selfhost.services.<name>.displayName

Human-readable name (defaults to attribute name)

Type: string

Default:

"‹name›"

Declared by:

selfhost.services.<name>.extraConfig

Consumer-owned per-service data with no first-class option (e.g. a landing-page tag); selfhost-nix never reads it. Co-located on the service entry instead of a parallel tree keyed by name. Read back at config.selfhost.services.<name>.extraConfig.

Type: open submodule of attribute set of anything

Default:

{ }

Declared by:

selfhost.services.<name>.healthcheck.path

Path for health checks (used by monitoring and homepage)

Type: string

Default:

"/"

Declared by:

selfhost.services.<name>.healthcheck.probeModule

Blackbox exporter module for health probes. Use http_any for services that require authentication on all endpoints.

Type: one of “http_2xx”, “http_any”

Default:

"http_2xx"

Declared by:

selfhost.services.<name>.healthcheck.url

Full health check URL (derived from url and healthcheck path)

Type: string (read only)

Default: <url><healthcheck.path>

Declared by:

selfhost.services.<name>.host

Hostname or IP where the service listens (local or remote)

Type: string

Default: 127.0.0.1, or the backend entry’s host

Declared by:

selfhost.services.<name>.ingress.enable

Whether to enable HTTP ingress route for this service.

Type: boolean

Default: on once the entry has a backend to route to and its access.model is satisfied

Example:

true

Declared by:

selfhost.services.<name>.integrations.homepage

Homepage dashboard integration

Type: submodule

Default:

{ }

Declared by:

selfhost.services.<name>.integrations.homepage.enable

Whether to enable homepage entry for this service.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.services.<name>.integrations.homepage.group

Free-form tile group this service belongs to; you map groups to tabs/layout in your dashboard.

Type: string

Default:

"Services"

Declared by:

selfhost.services.<name>.integrations.homepage.icon

Icon name from dashboard-icons (e.g. ‘miniflux.svg’)

Type: null or string

Default:

"‹name›.svg"

Declared by:

selfhost.services.<name>.integrations.homepage.settings

Extra homepage tile settings merged into the generated entry (e.g. a widget).

Type: attribute set

Default:

{ }

Declared by:

selfhost.services.<name>.integrations.monitoring.enable

Whether to enable monitoring for this service.

Type: boolean

Default: follows selfhost.monitoring.enable

Example:

true

Declared by:

selfhost.services.<name>.integrations.monitoring.exporters

Custom Prometheus exporters for this service

Type: attribute set of anything

Default:

{ }

Declared by:

selfhost.services.<name>.integrations.monitoring.healthcheck

Whether to auto-generate a blackbox healthcheck probe for this service

Type: boolean

Default:

true

Declared by:

selfhost.services.<name>.integrations.monitoring.rules

Custom Prometheus alert rule groups

Type: list of (attribute set)

Default:

[ ]

Declared by:

selfhost.services.<name>.integrations.monitoring.scrapeConfigs

Custom Prometheus scrape configurations

Type: list of (attribute set)

Default:

[ ]

Declared by:

selfhost.services.<name>.integrations.monitoring.systemdOverrides

Systemd service overrides for monitoring-related units

Type: attribute set of anything

Default:

{ }

Declared by:

selfhost.services.<name>.integrations.notify

notification integration

Type: submodule

Default:

{ }

Declared by:

selfhost.services.<name>.integrations.notify.enable

Publish notifications for this service/task.

Type: boolean

Default: on when a notify provider is active and topic is set

Declared by:

selfhost.services.<name>.integrations.notify.failureTopic

Topic for task-failure notifications. Defaults to topic; point it somewhere louder to separate failures from routine summaries. The publisher is granted write on both.

Type: null or impossible (empty enum)

Default: topic

Declared by:

selfhost.services.<name>.integrations.notify.titlePrefix

Prepended to failure-notification titles as <prefix>: . Set it when several hosts publish to one topic, where the unit name alone does not say which host failed.

Type: string

Default:

""

Declared by:

selfhost.services.<name>.integrations.notify.tokenFile

Path to this publisher’s access token, root-owned 0400. A host running the notify provider provisions it at the default path. A host that only publishes to another host’s provider points this at its own secret instead. How to read it depends on the publisher’s user:

  • runs as root (e.g. backup, task failure-hooks): read this path directly, at send time. Best-effort — no dependency on the provider being up, so notify never blocks the publisher.
  • runs as a non-root user (e.g. transmission): receive it via systemd LoadCredential = [ "notify-token:${...tokenFile}" ] and read %d/notify-token ($CREDENTIALS_DIRECTORY/notify-token). Because LoadCredential reads the source at unit start, order the unit after the provider’s provisioning unit (selfhost.notify.provisioningUnit).

Type: string

Default:

"/var/lib/homelab-secrets/notify-publishers/‹name›"

Declared by:

selfhost.services.<name>.integrations.notify.topic

Notification topic this service/task publishes to (null = none). A task falls back to notify.defaultTopic, since the hook reporting its failure is the framework’s own; a service names one when it has something to say.

Type: null or impossible (empty enum)

Default: notify.defaultTopic for a task, null for a service

Declared by:

selfhost.services.<name>.meta.category

Canonical grouping key; a consumer surface (e.g. a landing page) groups services by this.

Type: null or string

Default:

null

Declared by:

selfhost.services.<name>.meta.description

Short description (nixpkgs meta.description); shown under the name on a dashboard tile. Empty renders no subtitle.

Type: string

Default:

""

Declared by:

selfhost.services.<name>.meta.homepage

Upstream project homepage (nixpkgs meta.homepage).

Type: null or string

Default:

null

Declared by:

selfhost.services.<name>.name

Registry identifier (defaults to attribute name)

Type: string

Default:

"‹name›"

Declared by:

selfhost.services.<name>.ownsBackend

Whether this entry’s host:port is a socket it actually listens on (read-only).

Type: boolean (read only)

Default: true when port is set and backend is not

Declared by:

selfhost.services.<name>.port

Port the service listens on. Leave unset for a service with no HTTP backend (a UDP daemon, say).

Type: 16 bit unsigned integer; between 0 and 65535 (both inclusive)

Declared by:

selfhost.services.<name>.publicHost

Public hostname (derived from subdomain and ingress.domain)

Type: string

Default: <subdomain>.<ingress.domain>

Declared by:

selfhost.services.<name>.publicUrl

Full public URL (derived from publicHost)

Type: string

Default: https://<publicHost>

Declared by:

selfhost.services.<name>.scheme

URL scheme for backend connection

Type: one of “http”, “https”

Default: http, or the backend entry’s scheme

Declared by:

selfhost.services.<name>.storage.mounts

Named selfhost SMB shares this entry may access; their automount guards start before its units.

Type: list of impossible (empty enum)

Default:

[ ]

Declared by:

selfhost.services.<name>.storage.users

POSIX users this entry’s units run as. Each joins the group of every mounts share that grants by group, so adding a share is one edit rather than two. Empty grants nothing, which is right for a unit running as root or under DynamicUser. A share owned by a uid is skipped, so a second consumer of one still needs its group granted by hand.

Type: list of non-empty string

Default:

[ ]

Example:

[
  "romm"
  "nginx"
]

Declared by:

selfhost.services.<name>.subdomain

Subdomain prefix (combined with domain for publicHost)

Type: string

Default:

"‹name›"

Declared by:

selfhost.services.<name>.systemdServices

Systemd units this service owns; selfhost concerns attach to them (storage automount guards, failure notifications).

Names are trusted, not checked: attaching ordering to a unit is what defines it, so a name that matches nothing yields a stub carrying only the attachment rather than an error. Set this when the unit is not named after the service.

Type: (list of string) or string convertible to it

Default: the backend entry’s units, else the matching OCI container’s unit, else <name>

Declared by:

selfhost.services.<name>.traefik.middlewares

Extra Traefik middleware definitions to attach to this service’s router

Type: attribute set of attribute set of unspecified value

Default:

{ }

Declared by:

selfhost.services.<name>.url

Full URL for proxying (derived from scheme, host and port)

Type: string

Default: <scheme>://<host>:<port>

Declared by:

selfhost.storage.mounts.smb.enable

Whether to enable Home-server storage.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.storage.mounts.smb.credentialsPath

Path to the SMB credentials file (must be provided by the host, e.g. via sops-nix)

Type: string

Declared by:

selfhost.storage.mounts.smb.hostname

IP or hostname of the SMB server; prefer an IP or /etc/hosts entry for reliable resolution at boot.

Type: string

Declared by:

selfhost.storage.mounts.smb.shares

CIFS shares keyed by remote root folder, each behind a dedicated access group and mounted on demand. Boot does not wait for the SMB server. First access may wait up to 30 seconds; after a failed mount, a later access retries.

Type: attribute set of (submodule)

Default:

{ }

Example:

{
  bphenriques = { };
  media = { };
}

Declared by:

selfhost.storage.mounts.smb.shares.<name>.gid

GID for the mount group (required for SMB mount options)

Type: signed integer

Declared by:

selfhost.storage.mounts.smb.shares.<name>.group

Name of the group with access to the mount

Type: string

Default:

"homelab-‹name›"

Declared by:

selfhost.storage.mounts.smb.shares.<name>.localMount

Local mount point for the share

Type: string

Default:

"/mnt/homelab-‹name›"

Declared by:

selfhost.storage.mounts.smb.shares.<name>.systemd.dependentServices

Extra systemd service names to order after this share’s automount guard.

Type: list of string

Default:

[ ]

Declared by:

selfhost.storage.mounts.smb.shares.<name>.uid

File-owner UID on the client (default 0/root → access via group; set per-user for owner-level ops like chmod/git).

Type: signed integer

Default:

0

Declared by:

selfhost.storage.shares.smb.enable

Whether to enable an SMB server exporting the shares declared here.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.storage.shares.smb.openFirewall

Whether to open TCP 445. Only 445: netbios is disabled, so 137-139 never listen.

Type: boolean

Default:

false

Declared by:

selfhost.storage.shares.smb.shares

Shares exported over SMB, keyed by the name clients connect to.

Type: attribute set of (submodule)

Default:

{ }

Declared by:

selfhost.storage.shares.smb.shares.<name>.access

Who may reach this share, and at what level. Groups grant; a users entry overrides the group result for that principal.

Type: submodule

Default:

{ }

Declared by:

selfhost.storage.shares.smb.shares.<name>.access.groups

Groups let into this share, by selfhost.users.<name>.groups. Additive: a principal holding several gets the most permissive. No none here, because a group grant is taken away per principal under users.

Type: attribute set of (one of “rw”, “ro”)

Default:

{ }

Example:

{
  family = "rw";
}

Declared by:

selfhost.storage.shares.smb.shares.<name>.access.users

Principals granted directly. Takes precedence over groups for that principal, so none revokes what a group gave. Every name must be a selfhost.users or selfhost.serviceAccounts entry with storage.smb.enable. Service accounts hold no groups, so they are always named here.

Type: attribute set of (one of “rw”, “ro”, “none”)

Default:

{ }

Example:

{
  machine-backup = "ro";
  teenager = "none";
}

Declared by:

selfhost.storage.shares.smb.shares.<name>.directories

Paths under the share carrying its ownership. Plain ones are created; ones the host mounted keep their contents and only have ownership fixed.

Type: list of string

Default:

[ ]

Example:

[
  "movies"
]

Declared by:

selfhost.storage.shares.smb.shares.<name>.gid

As in users.groups.<name>.gid. Null picks a free one on activation; pin it, since the share data outlives the root filesystem that recorded the allocation.

Type: null or signed integer

Default:

null

Declared by:

selfhost.storage.shares.smb.shares.<name>.group

Group owning the share, forced onto everything written into it. Its members are whoever access resolves to.

Type: string

Default: storage-<name>

Declared by:

selfhost.storage.shares.smb.shares.<name>.owner

Unix owner of the share root. Independent of access: a share may be owned by a principal holding no grant on it, as when its data is reached through an application rather than over SMB.

Type: string

Default:

"root"

Declared by:

selfhost.storage.shares.smb.shares.<name>.path

Absolute path exported under this share name. The host prepares and mounts it; this module never creates the backing store.

Type: string

Example:

"/srv/storage/media"

Declared by:

selfhost.tasks

Registry of externally-defined systemd units that opt into selfhost concerns (notify, storage).

Type: attribute set of (submodule)

Default:

{ }

Declared by:

selfhost.tasks.<name>.integrations.notify

notification integration

Type: submodule

Default:

{ }

Declared by:

selfhost.tasks.<name>.integrations.notify.enable

Publish notifications for this service/task.

Type: boolean

Default: on when a notify provider is active and topic is set

Declared by:

selfhost.tasks.<name>.integrations.notify.failureTopic

Topic for task-failure notifications. Defaults to topic; point it somewhere louder to separate failures from routine summaries. The publisher is granted write on both.

Type: null or impossible (empty enum)

Default: topic

Declared by:

selfhost.tasks.<name>.integrations.notify.titlePrefix

Prepended to failure-notification titles as <prefix>: . Set it when several hosts publish to one topic, where the unit name alone does not say which host failed.

Type: string

Default:

""

Declared by:

selfhost.tasks.<name>.integrations.notify.tokenFile

Path to this publisher’s access token, root-owned 0400. A host running the notify provider provisions it at the default path. A host that only publishes to another host’s provider points this at its own secret instead. How to read it depends on the publisher’s user:

  • runs as root (e.g. backup, task failure-hooks): read this path directly, at send time. Best-effort — no dependency on the provider being up, so notify never blocks the publisher.
  • runs as a non-root user (e.g. transmission): receive it via systemd LoadCredential = [ "notify-token:${...tokenFile}" ] and read %d/notify-token ($CREDENTIALS_DIRECTORY/notify-token). Because LoadCredential reads the source at unit start, order the unit after the provider’s provisioning unit (selfhost.notify.provisioningUnit).

Type: string

Default:

"/var/lib/homelab-secrets/notify-publishers/‹name›"

Declared by:

selfhost.tasks.<name>.integrations.notify.topic

Notification topic this service/task publishes to (null = none). A task falls back to notify.defaultTopic, since the hook reporting its failure is the framework’s own; a service names one when it has something to say.

Type: null or impossible (empty enum)

Default: notify.defaultTopic for a task, null for a service

Declared by:

selfhost.tasks.<name>.name

Task identifier (defaults to the attribute name).

Type: string

Default:

"‹name›"

Declared by:

selfhost.tasks.<name>.storage.mounts

Named selfhost SMB shares this entry may access; their automount guards start before its units.

Type: list of impossible (empty enum)

Default:

[ ]

Declared by:

selfhost.tasks.<name>.storage.users

POSIX users this entry’s units run as. Each joins the group of every mounts share that grants by group, so adding a share is one edit rather than two. Empty grants nothing, which is right for a unit running as root or under DynamicUser. A share owned by a uid is skipped, so a second consumer of one still needs its group granted by hand.

Type: list of non-empty string

Default:

[ ]

Example:

[
  "romm"
  "nginx"
]

Declared by:

selfhost.tasks.<name>.systemdServices

Systemd units this task owns; selfhost concerns attach to them (storage automount guards, failure notifications). Names are trusted, not checked.

Type: (list of string) or string convertible to it

Default: <name>

Declared by:

selfhost.users

People, keyed by username. Non-human principals belong in selfhost.serviceAccounts: this schema requires a name and an email and layers OIDC onto every entry, which a robot has neither of. Per-service config mirrors the registry at users.<name>.services.<service>; a concern’s per-user opt-in mirrors the concern, e.g. users.<name>.auth.oidc.enable. One admin is required once any service is registered.

Type: attribute set of (submodule)

Default:

{ }

Declared by:

selfhost.users.<name>.auth.oidc.enable

Whether to enable OIDC account for this user.

Type: boolean

Default:

true

Example:

true

Declared by:

selfhost.users.<name>.auth.oidc.inviteByEmail

Whether to enable emailing this user a one-time enrolment link when the account is first created; otherwise provisioning prints the command to mint one.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.users.<name>.email

Email address, used by services that identify accounts by one (OIDC, Gitea, Immich).

Type: string

Declared by:

selfhost.users.<name>.extraConfig

Consumer-owned per-user data with no first-class option; selfhost-nix never reads it. Per-service config mirrors the registry at users.<name>.services.<name>, not here. Read back at config.selfhost.users.<name>.extraConfig.

Type: open submodule of attribute set of anything

Default:

{ }

Declared by:

selfhost.users.<name>.firstName

Given name, used to build name and by services that store it separately.

Type: string

Declared by:

selfhost.users.<name>.groups

Groups assigned to this user. If admin group is included, the user is marked as admin.

Type: list of string

Default:

[ ]

Declared by:

selfhost.users.<name>.isAdmin

Whether this user is an admin (derived from groups; set the group, not this).

Type: boolean (read only)

Default: true if the user’s groups include the admin group

Declared by:

selfhost.users.<name>.lastName

Family name, used to build name and by services that store it separately.

Type: string

Declared by:

selfhost.users.<name>.name

Display name shown by services that take a single full name.

Type: string

Default: <firstName> <lastName>, and just <firstName> when lastName is empty

Declared by:

selfhost.users.<name>.services.couchdb.enable

Whether to enable a CouchDB account for this user.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.users.<name>.services.couchdb.databases

Databases created for this user, with the user as their sole admin and member.

Type: list of string

Default:

[ ]

Declared by:

selfhost.users.<name>.services.filebrowser-quantum.enable

Whether to enable a FileBrowser entry for this user (access is gated by the service auth, not this flag).

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.users.<name>.services.filebrowser-quantum.admin

Whether to enable FileBrowser admin.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.users.<name>.services.filebrowser-quantum.storage

selfhost SMB mounts this user may access, keyed by permission; unioned into their scope (read-write iff any is rw).

Type: attribute set of (one of “ro”, “rw”)

Default:

{ }

Declared by:

selfhost.users.<name>.services.gitea.enable

Whether to enable a Gitea account for this user.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.users.<name>.services.gitea.admin

Gitea site-admin (reconciled each run); defaults to the user’s fleet isAdmin.

Type: boolean

Default:

false

Declared by:

selfhost.users.<name>.services.immich.enable

Whether to enable an Immich account for this user.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.users.<name>.services.immich.admin

Grant this account Immich administrator rights.

Type: boolean

Default: the user’s isAdmin

Declared by:

selfhost.users.<name>.services.immich.libraries

External libraries owned by this user: existing directories Immich scans in place. Empty leaves the account upload-only, which is the common case.

Type: list of (submodule)

Default:

[ ]

Declared by:

selfhost.users.<name>.services.immich.libraries.*.exclusionPatterns

Glob patterns skipped during the scan.

Type: list of string

Default:

[ ]

Declared by:

selfhost.users.<name>.services.immich.libraries.*.importPaths

Directories Immich scans for this library. The app grants the service write access to them.

Type: list of string

Default:

[ ]

Declared by:

selfhost.users.<name>.services.immich.libraries.*.name

Library name as shown in Immich; also its reconcile identity, so renaming creates a new one.

Type: string

Declared by:

selfhost.users.<name>.services.immich.passwordFile

Path to a file holding the initial password, applied at account creation and never reconciled. Null generates a random one, for accounts that sign in through OIDC.

Type: null or string

Default:

null

Declared by:

selfhost.users.<name>.services.immich.quotaBytes

Upload quota for this account in bytes. Null is unlimited. Reconciled, so changing it moves the existing account’s quota.

Type: null or (unsigned integer, meaning >=0)

Default:

null

Example:

107374182400

Declared by:

selfhost.users.<name>.services.immich.storageLabel

Folder name for this account under a {{label}} storage template. Null falls back to the account’s UUID. Reconciled.

Type: null or string

Default:

null

Declared by:

selfhost.users.<name>.services.jellyfin.enable

Whether to enable a Jellyfin account for this user.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.users.<name>.services.jellyfin.passwordFile

Path to a file holding the initial password, applied at account creation and never reconciled. Null generates a random one.

Type: null or string

Default:

null

Declared by:

selfhost.users.<name>.services.jellyfin.policy

Fields merged into this account’s Jellyfin UserPolicy on every run, applied verbatim. Anything not named here keeps whatever Jellyfin holds.

Type: attribute set of anything

Default:

{ }

Example:

{
  EnableSubtitleManagement = true;
  IsHidden = false;
}

Declared by:

selfhost.users.<name>.services.miniflux.settings

Per-user Miniflux preferences, applied verbatim via the user-update API (is_admin is framework-managed and ignored here).

Type: attribute set of anything

Default:

{ }

Example:

{
  display_mode = "fullscreen";
  theme = "dark_serif";
}

Declared by:

selfhost.users.<name>.services.radicale.enable

Whether to enable Radicale CalDAV/CardDAV access for this user.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.users.<name>.storage.smb.enable

Whether to enable an SMB account for this principal on a host serving selfhost.storage.shares.smb.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.users.<name>.storage.smb.passwordFile

File holding this principal’s SMB password, read at activation through a credential and never placed in the store. Generated by default, like Radicale’s and CouchDB’s: a person types it into a device once, so nobody needs to pick it. Set it instead when the same value has to exist somewhere else too, as for a machine principal whose client reads it from your own secret store: generating here would leave you hand-copying it there.

Type: string

Default: a password generated for this principal under runtimeSecretsDir

Declared by:

selfhost.users.<name>.unixAccount.enable

Whether to enable a POSIX account for this person on the host that declares it, for services that resolve a session to a Unix identity. Identity only: no home and no shell, since the account exists for a service rather than for signing in. Add those on users.users.<name> where a person actually does.

Type: boolean

Default:

false

Example:

true

Declared by:

selfhost.users.<name>.unixAccount.uid

As in users.users.<name>.uid. Null picks a free one on activation; pin it where this account owns data that outlives the root filesystem, since the allocation is recorded only on root.

Type: null or signed integer

Default:

null

Declared by:

selfhost.users.<name>.username

Login name used by the services this user is enabled on.

Type: string

Default: the attribute name

Declared by: