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.oidcblock and setaccess.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.