Creating a "service" type module
A DNF “service” module wires a self-hosted piece of software (web UI, API,
storage…) into the framework: reverse proxy, homepage, persistence, firewall,
SSO and per-host activation. This page describes how to create and maintain such
a module, from header to commit, using the oxicloud module as a running
example.
Module anatomy
Section titled “Module anatomy”A module lives in dnf/modules/service/<name>.nix. It is auto-imported
via the generated default.nix (run just generate after creation).
Header
Section titled “Header”Mandatory top comment: 1 descriptive line, then paragraphs and Starlight
admonitions (note, tip, caution, danger) oriented towards usage /
maintenance.
# OxiCloud — Fast Sovereign Cloud (file storage, WebDAV, CalDAV & CardDAV).## :::note[Service currently being validated]# DNF wrapper around the nixpkgs module (from PR #516113).# :::Options
Section titled “Options”Imposed schema: darkone.service.<name>.<option>. Start with only the enable
option; only add options for matters not resolved by the framework.
options = { darkone.service.oxicloud.enable = lib.mkEnableOption "Enable local OxiCloud service";};config structure
Section titled “config structure”Always lib.mkMerge of two blocks:
- Unconditional block — registration with the service and template system (proxy, persist, OAuth2…). Always evaluated, even if the service is disabled.
lib.mkIf cfg.enableblock — the actual NixOS configuration (upstream service, firewall, secrets…).
config = lib.mkMerge [ { darkone.system.services.service.oxicloud = { /* registration */ }; darkone.service.idm.oauth2.oxicloud = { /* template OAuth2 */ }; } (lib.mkIf cfg.enable { darkone.system.services = dnfLib.enableBlock "oxicloud"; services.oxicloud = { /* … */ }; })];Two-block structure:
Injected arguments and consumer boundary
Section titled “Injected arguments and consumer boundary”Besides lib, config, pkgs, each service module receives specialArgs:
host— current host (hostname,zone,ip,vpnIp,services,profile…).hosts— all hosts in the deployment (all zones).zone— current host’s zone (name,domain,gateway,lang…).network— global config (domain,coordination,services,zones).dnfLib— framework helpers (never importdnf/lib/manually).dnfConfig— framework config registers (dnfConfig.network.ports.<key>: port registry).workDir— consumer workspace root.users— all users.
Registration and activation
Section titled “Registration and activation”Service exposition
Section titled “Service exposition”The unconditional block declares darkone.system.services.service.<name>,
read by modules/system/services.nix to generate reverse proxy, homepage,
persistence and backup.
darkone.system.services.service.oxicloud = { inherit defaultParams; # title / description / icon homepage persist = { dirs = [ oxCfg.dataDir ]; # service data dbDirs = [ config.services.postgresql.dataDir ]; }; proxy.servicePort = srvPort; # Caddy proxies to this port};proxy.servicePort— internal HTTP port; the Caddy reverse proxy connects to it onparams.ip. Without reverse proxy:proxy.servicePort = null+proxy.enable = false.persist.{dirs,dbDirs,varDirs,mediaDirs,…}— persisted / backed up paths.defaultParams— default metadata (title,description,icon), overridable fromconfig.yaml. Theiconuses a selfh.st 🡕 slug (prefixedsh-by the framework).
defaultParams = { title = "OxiCloud"; description = "Fast Sovereign Cloud"; icon = "oxicloud";};Protocols served on the same HTTP port (WebDAV, CalDAV, CardDAV for OxiCloud) naturally go through the single Caddy vhost: nothing special to configure.
Per-host activation
Section titled “Per-host activation”A service is activated on a host when it appears under services: in
etc/config.yaml, or via a trigger in dnf/config/modules.nix:
oxicloud = { # reverseProxy = true by default (web service with UI/API). activation.profiles.minimal.triggers.keys.oxicloud = [ "enable" ];};Available flags:
| Flag | Default | Role |
|---|---|---|
reverseProxy | true | Exposes the service via Caddy |
uniquePerZone | false | Single instance per zone |
externalAccess | false | Access from outside |
On the config.yaml side, the following
example installs a full OxiCloud on https://cloud.mydomain.tld:
hosts: - hostname: "my-server" services: oxicloud: domain: "cloud" global: trueThe framework resolves domain/global into params (fqdn, href, ip…)
via dnfLib.extractServiceParams host network "oxicloud" defaultParams.
Network, database and secrets
Section titled “Network, database and secrets”Internal ports
Section titled “Internal ports”Any internal port that a module binds, opens in the firewall or proxies
must come from the central registry dnf/config/network.nix, read via
dnfConfig.network.ports.<key> — never a hard-coded literal in the module.
This is the single source of truth: the same number is never written twice and
two services cannot silently conflict.
-
Name the key
<service>[Usage]— theports.prefix already says “port”, so the key specifies what it is for (kanidmReplication,garageRpc). A bare<service>denotes the main port. Keepnetwork.nixsorted by value. -
Add the value in
ports.<key>when DNF binds the port itself, then read it in the module:srvPort = dnfConfig.network.ports.oxicloud; -
Port inherited from upstream (
config.services.<x>.port, not bound by DNF): instead register it inports.reserved(anti-conflict denylist); the module continues to readconfig.services.<x>.port.
Internal firewall
Section titled “Internal firewall”Web service behind Caddy → no external opening. Open the port on internal
interfaces only with dnfLib.mkInternalFirewall:
networking.firewall = dnfLib.mkInternalFirewall host zone [ srvPort ];Local PostgreSQL
Section titled “Local PostgreSQL”Many upstream modules expose a flag for local database creation. For OxiCloud, the option is sufficient (peer auth via socket):
services.oxicloud.createLocalDatabase = true; # creates DB + role "oxicloud"services.postgresqlBackup.enable = true; # backupPersist the PostgreSQL dataDir via persist.dbDirs (see above). Without an
upstream option, create DB and role manually (see matrix.nix).
Sops secrets
Section titled “Sops secrets”- Declare
sops.secrets.<name> = { mode = "0400"; owner = "<svc-user>"; }(default fileusr/secrets/secrets.yaml). - A
DynamicUser = trueservice cannot own a sops secret. If it loads the key viaLoadCredential(read as root), a root0400secret suffices; otherwise, fall back to a static group +SupplementaryGroups.
OIDC Authentication with Kanidm
Section titled “OIDC Authentication with Kanidm”SSO wiring is automatic and conditional on the presence of an idm
(Kanidm) service on the network (zone or global). Three ingredients:
- OAuth2 Template (unconditional block):
darkone.service.idm.oauth2.<name>. Kanidm provisions it as an OAuth2 client whenidmis enabled. - OIDC Context:
dnfLib.mkOidcContext→clientId,secret,idmUrl.idmUrl == nullwhen noidmexists: all wiring is then cut off. - Endpoints:
dnfLib.mkKanidmEndpoints idmUrl clientId→issuerUrl,authUrl,tokenUrl,userinfoUrl…
params = dnfLib.extractServiceParams host network "oxicloud" defaultParams;inherit (dnfLib.mkOidcContext { name = "oxicloud"; inherit params network hosts;}) clientId secret idmUrl;oidc = dnfLib.mkKanidmEndpoints idmUrl clientId;hasIdm = idmUrl != null;The template declares redirect paths (prefixed by the service’s public URL framework) and a landing page:
darkone.service.idm.oauth2.oxicloud = { displayName = "OxiCloud"; imageFile = ./../../assets/app-icons/oxicloud.svg; redirectPaths = [ "/api/auth/oidc/callback" ]; landingPath = "/";};The OAuth2 secret is generated on the Kanidm side (oidc-secret-<clientId>). To
provide it to the service without storing it in the Nix store, re-encrypt it
for the service user (sops key field) then inject it via an environment file
(sops.templates, KEY=value format):
sops.secrets."${secret}-service" = lib.mkIf hasIdm { mode = "0400"; owner = "oxicloud"; key = secret; # master secret alias};
sops.templates."oxicloud-oidc-env" = lib.mkIf hasIdm { content = "OXICLOUD_OIDC_CLIENT_SECRET=${config.sops.placeholder."${secret}-service"}"; mode = "0400"; owner = "oxicloud"; restartUnits = [ "oxicloud.service" ];};
services.oxicloud = { settings.oidc = lib.mkIf hasIdm { enable = true; issuerUrl = oidc.issuerUrl; inherit clientId; redirectUri = "${params.href}/api/auth/oidc/callback"; frontendUrl = params.href; }; environmentFiles = lib.mkIf hasIdm [ config.sops.templates."oxicloud-oidc-env".path ];};Source an upstream module
Section titled “Source an upstream module”When only the NixOS module is missing (package already in nixpkgs), source it from a fork carrying the PR without rebuilding it.
-
Add a dedicated input in
dnf/flake.nixpointing to the fork branch, withoutinputs.nixpkgs.follows(we want the fork tree as-is):nixpkgs-oxicloud.url = "github:flashonfire/nixpkgs/oxicloud-service"; -
Inject the module path into the
moduleslist ofmkNode(dnf/lib/mk-configuration.nix) — parsed unconditionally, no effect as long asservices.<name>.enable = false:"${nixpkgs-oxicloud}/nixos/modules/services/web-apps/oxicloud.nix"
Finalize and commit
Section titled “Finalize and commit”just generate— regeneratedefault.nix(auto-import the new module).just clean— fix + check + generate + format (mandatory before commit).- Evaluate / build a host to validate, then commit.
Commit message: one line, 80 characters max, English, <type>(<subject>): <message> format — e.g. module(oxicloud): full DNF service with OIDC.