Skip to content

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.

A module lives in dnf/modules/service/<name>.nix. It is auto-imported via the generated default.nix (run just generate after creation).

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).
# :::

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";
};

Always lib.mkMerge of two blocks:

  1. Unconditional block — registration with the service and template system (proxy, persist, OAuth2…). Always evaluated, even if the service is disabled.
  2. lib.mkIf cfg.enable block — 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:

Diagram

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 import dnf/lib/ manually).
  • dnfConfig — framework config registers (dnfConfig.network.ports.<key>: port registry).
  • workDir — consumer workspace root.
  • users — all users.

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 on params.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 from config.yaml. The icon uses a selfh.st 🡕 slug (prefixed sh- 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.

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:

FlagDefaultRole
reverseProxytrueExposes the service via Caddy
uniquePerZonefalseSingle instance per zone
externalAccessfalseAccess 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: true

The framework resolves domain/global into params (fqdn, href, ip…) via dnfLib.extractServiceParams host network "oxicloud" defaultParams.

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.

  1. Name the key <service>[Usage] — the ports. prefix already says “port”, so the key specifies what it is for (kanidmReplication, garageRpc). A bare <service> denotes the main port. Keep network.nix sorted by value.

  2. Add the value in ports.<key> when DNF binds the port itself, then read it in the module:

    srvPort = dnfConfig.network.ports.oxicloud;
  3. Port inherited from upstream (config.services.<x>.port, not bound by DNF): instead register it in ports.reserved (anti-conflict denylist); the module continues to read config.services.<x>.port.

Web service behind Caddy → no external opening. Open the port on internal interfaces only with dnfLib.mkInternalFirewall:

networking.firewall = dnfLib.mkInternalFirewall host zone [ srvPort ];

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; # backup

Persist the PostgreSQL dataDir via persist.dbDirs (see above). Without an upstream option, create DB and role manually (see matrix.nix).

  • Declare sops.secrets.<name> = { mode = "0400"; owner = "<svc-user>"; } (default file usr/secrets/secrets.yaml).
  • A DynamicUser = true service cannot own a sops secret. If it loads the key via LoadCredential (read as root), a root 0400 secret suffices; otherwise, fall back to a static group + SupplementaryGroups.

SSO wiring is automatic and conditional on the presence of an idm (Kanidm) service on the network (zone or global). Three ingredients:

  1. OAuth2 Template (unconditional block): darkone.service.idm.oauth2.<name>. Kanidm provisions it as an OAuth2 client when idm is enabled.
  2. OIDC Context: dnfLib.mkOidcContextclientId, secret, idmUrl. idmUrl == null when no idm exists: all wiring is then cut off.
  3. Endpoints: dnfLib.mkKanidmEndpoints idmUrl clientIdissuerUrl, 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 ];
};

When only the NixOS module is missing (package already in nixpkgs), source it from a fork carrying the PR without rebuilding it.

  1. Add a dedicated input in dnf/flake.nix pointing to the fork branch, without inputs.nixpkgs.follows (we want the fork tree as-is):

    nixpkgs-oxicloud.url = "github:flashonfire/nixpkgs/oxicloud-service";
  2. Inject the module path into the modules list of mkNode (dnf/lib/mk-configuration.nix) — parsed unconditionally, no effect as long as services.<name>.enable = false:

    "${nixpkgs-oxicloud}/nixos/modules/services/web-apps/oxicloud.nix"
  1. just generate — regenerate default.nix (auto-import the new module).
  2. just clean — fix + check + generate + format (mandatory before commit).
  3. 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.