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 header comment: 1st descriptive line, then paragraphs and
Starlight admonitions (note, tip, caution, danger) oriented toward
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 system and
-
lib.mkIf cfg.enableblock: the actual NixOS configuration (the upstream service, the firewall, the 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”Beyond 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 registries (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 itpersist.{dirs,dbDirs,varDirs,mediaDirs,…}: persisted / backed-up paths.defaultParams: default metadata (title,description,icon), overridable fromconfig.yaml. Theiconuses the 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 activates 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 | External access |
require | [ ] | Dependencies to activate on the same host (error otherwise) |
On the config.yaml side, the following
example installs a full OxiCloud on https://cloud.mondomaine.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 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 hardcoded literal in the module.
It 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 used for (kanidmReplication,garageRpc). A<service>alone designates the main port. Keepnetwork.nixsorted by value. -
Add the value in
ports.<key>when DNF itself binds the port, then read it in the module:srvPort = dnfConfig.network.ports.oxicloud; -
Port inherited from upstream (
config.services.<x>.port, not bound by DNF): register it inports.reserved(anti-conflict denylist) instead; 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 ];};Protecting a service without OIDC
Section titled “Protecting a service without OIDC”When the service cannot speak OIDC (landing page, static site, UI without
auth), DNF protects its vhost upstream via oauth2-proxy + Caddy forward
auth, backed by Kanidm. A single oauth2-proxy instance per gateway, shared
by all protected services in the zone. Everything is declarative on the service
record (unconditional block), via two proxy options:
darkone.system.services.service.oxicloud = { inherit defaultParams; proxy.servicePort = srvPort; proxy.isProtected = true; # places the vhost behind oauth2-proxy proxy.allowedGroups = [ "users" ]; # allowed groups (SPN added by the framework)};| Option | Default | Role |
|---|---|---|
proxy.isProtected | false | Puts the vhost behind oauth2-proxy (forward auth) |
proxy.allowedGroups | [ ] | Allowed Kanidm groups (empty: any authenticated user) |
Sourcing an upstream module
Section titled “Sourcing 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 in
dnf/flake.nixa dedicated input pointing 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, with 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: regeneratesdefault.nix(auto-import of 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, format
<type>(<subject>): <message>, e.g. module(oxicloud): full DNF service with OIDC.