Modular Services

Authors

K19G

grok-bot

Updated

October 31, 2026

Modular Services

Goal: Define a service once, as a module, and run several named instances of it from system.services.<name>. Ship a portable core (process.argv, configData) that a non-systemd service manager could also consume, and keep systemd-specific settings behind a guard. Compose services from other services, including modules that packages export as pkgs.<pkg>.services.default. Know which parts are still moving, and the four traps that bite first.

Version note: modular services first shipped in NixOS 25.11. The 26.05 manual still marks them “Status: in development … significant changes should be expected”, and user services are still “TBD”. Everything below was evaluated against nixos-26.05 (26.05.20261008.7c8764b) with Nix 2.35.2, and every unit file and error shown is real output from that evaluation. 26.05 adds two systemd-side options that 25.11 lacks, systemd.mainExecStart and systemd.lib.escapeSystemdExecArgs. Case 5 depends on them.

Mental model

Classic NixOS services are options in a module: services.nginx.enable = true gives you exactly one nginx, configured through a fixed option tree. A modular service turns that inside out: the service is the module. You import it into a named slot as many times as you like, and each slot becomes its own unit.

system.services.<name>  :: attrsOf (submodule, class "service")
        │
        ├── portable core (lib/services)          ← any service manager
        │     process.argv        command line, as a list (no shell)
        │     configData.<file>   text/source → path the manager exposes
        │     services.<sub>      sub-services this one owns
        │     meta.maintainers
        │
        └── systemd layer (nixos/modules/system/service/systemd)   ← NixOS only
              systemd.service      → systemd.services."<name>"
              systemd.services.<x> → extra units, prefixed with <name>
              systemd.socket(s)
              systemd.mainExecStart, systemd.lib.*   (26.05+)

NixOS realises it as:
  systemd.services.<name>                ExecStart from process.argv
  environment.etc."system-services/<name>/<file>"   from configData
  systemd.services.<name>-<sub>          for each sub-service
Piece What it means in practice
_class = "service" The module declares which evaluation it belongs to. Importing it anywhere else is an error, not a silent no-op (see The trap)
process.argv A list, not a shell string. NixOS escapes it for systemd, so % and $ reach the program literally
configData.<file>.text / .source Files the service reads. On NixOS, .path resolves to /etc/system-services/<name>/<file>
options ? systemd The portability guard. The systemd layer is present on NixOS and absent elsewhere
services.<sub> Ownership, not ordering. A sub-service unit gets no After= on its parent unless you add it
NixOS defaults Type=simple, Restart=always, RestartSec=5, WantedBy=multi-user.target, all overridable
Package-exported modules pkgs.<pkg>.services.default, imported inside a system.services.<name> slot

When to use what (26.05):

Need Prefer
One instance of a mature service with a big option tree (nginx, postgres) Classic services.<x> module
Several instances of the same thing with different settings Modular service, one slot per instance
Your own small daemon, written once and reused across hosts or managers Modular service with a portable core
A unit with no reuse value Plain systemd.services.<x> (see systemd via Nix)

Worked examples

Case 1: A portable service module

A status page: one static HTML file, served by darkhttpd. The page text and port are options. Nothing systemd-specific appears outside the guard.

# status-page.nix — a portable modular service: one static page, served by darkhttpd
# Non-module dependencies, passed with lib.modules.importApply
{ darkhttpd }:

# The service module
{ lib, config, options, ... }:
let
  cfg = config.statusPage;
in
{
  _class = "service";

  options.statusPage = {
    package = lib.mkOption {
      type = lib.types.package;
      default = darkhttpd;
      description = "Web server package.";
    };
    port = lib.mkOption {
      type = lib.types.port;
      description = "TCP port on 127.0.0.1.";
    };
    message = lib.mkOption {
      type = lib.types.str;
      default = "all systems nominal";
      description = "Text shown on the page.";
    };
  };

  config = {
    configData."index.html".text = ''
      <h1>${cfg.message}</h1>
    '';

    process.argv = [
      (lib.getExe cfg.package)
      (dirOf config.configData."index.html".path)
      "--port"
      (toString cfg.port)
      "--addr"
      "127.0.0.1"
    ];
  }
  // lib.optionalAttrs (options ? systemd) {
    # Only when the service manager is systemd (NixOS); ignored elsewhere.
    systemd.service.serviceConfig = {
      DynamicUser = true;
      Restart = "on-failure";       # the NixOS default is "always"
    };
  };
}

Three conventions here come straight from the nixpkgs contributor rules for modular services:

  • importApply for dependencies. The file is a function that takes its packages ({ darkhttpd }:) and returns the module. The module itself never sees pkgs, because a service manager other than NixOS might not have one.
  • configData instead of pkgs.writeText plus a hard-coded path. The service asks for a file, and the manager decides where it lives. config.configData."index.html".path is how the module finds out.
  • lib.optionalAttrs (options ? systemd) instead of mkIf. mkIf would still need the systemd option to exist. The attribute-set guard avoids even mentioning it when it doesn’t.

Case 2: Two instances on one host

# flake.nix — one host, two instances of the same modular service
{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";

  outputs = { nixpkgs, ... }: {
    nixosConfigurations.box = nixpkgs.lib.nixosSystem {
      modules = [
        ({ lib, pkgs, ... }:
          let
            statusPage = lib.modules.importApply ./status-page.nix {
              inherit (pkgs) darkhttpd;
            };
          in
          {
            nixpkgs.hostPlatform = "x86_64-linux";
            boot.isContainer = true;          # no bootloader/kernel needed for eval
            system.stateVersion = "26.05";

            system.services.status-public = {
              imports = [ statusPage ];
              statusPage.port = 8080;

              # a sub-service: TLS in front of the page, owned by status-public
              services.tls = {
                imports = [ pkgs.ghostunnel.services.default ];
                ghostunnel = {
                  listen = "0.0.0.0:8443";
                  target = "127.0.0.1:8080";
                  keystore = "/run/keys/status-public.pem";
                  disableAuthentication = true;
                };
              };
            };

            system.services.status-ops = {
              imports = [ statusPage ];
              statusPage.port = 8081;
              statusPage.message = "maintenance at 22:00 UTC";
            };
          })
      ];
    };
  };
}

boot.isContainer = true keeps this example evaluable without hardware configuration. On your lab host, add the two system.services blocks to the existing host module instead.

git add -A        # flakes only see tracked files
nix --version
nix eval --raw .#nixosConfigurations.box.config.system.nixos.version; echo
nix eval --json .#nixosConfigurations.box.config.systemd.services \
  --apply 's: builtins.filter (n: builtins.match "status-.*" n != null) (builtins.attrNames s)'; echo
nix eval --json .#nixosConfigurations.box.config.environment.etc \
  --apply 'e: builtins.filter (n: builtins.match "system-services/.*" n != null) (builtins.attrNames e)'; echo
nix (Nix) 2.35.2
26.05.20261008.7c8764b
["status-ops","status-public","status-public-tls"]
["system-services/status-ops/index.html","system-services/status-public/index.html"]

One module gave two services plus a third unit for the sub-service, named <parent>-<sub>. Each instance has its own copy of index.html under /etc/system-services/<name>/.

Case 3: Read the units NixOS generated

Evaluate the unit file without switching anything:

out=$(nix build --no-link --print-out-paths \
  '.#nixosConfigurations.box.config.systemd.units."status-ops.service".unit')
cat "$out/status-ops.service"
[Unit]

[Service]
Environment="LOCALE_ARCHIVE=/nix/store/dc427sjs33dci797apv4lvsg1a69szn0-glibc-locales-2.42-100/lib/locale/locale-archive"
Environment="PATH=/nix/store/bhv6xyvqw6gjs3j488gi1z6wdv18bdpa-coreutils-9.11/bin:/nix/store/nl0wq6d4bpjrxmlax141liq0qr9z87gn-findutils-4.10.0/bin:/nix/store/cqhgy07rpxdwk9kd2qlc3cw4s9ajzm9f-gnugrep-3.12/bin:/nix/store/0s17nja4n8b63by0nlg1m31mxn2bq1g1-gnused-4.10/bin:/nix/store/kmzlca218j6j1ar292fxj7707qzypij8-systemd-260.5/bin:/nix/store/bhv6xyvqw6gjs3j488gi1z6wdv18bdpa-coreutils-9.11/sbin:/nix/store/nl0wq6d4bpjrxmlax141liq0qr9z87gn-findutils-4.10.0/sbin:/nix/store/cqhgy07rpxdwk9kd2qlc3cw4s9ajzm9f-gnugrep-3.12/sbin:/nix/store/0s17nja4n8b63by0nlg1m31mxn2bq1g1-gnused-4.10/sbin:/nix/store/kmzlca218j6j1ar292fxj7707qzypij8-systemd-260.5/sbin"
Environment="TZDIR=/nix/store/mkbbamycmw838fwxk21jz0nw3mhnmk16-tzdata-2026c/share/zoneinfo"
DynamicUser=true
ExecStart="/nix/store/qc2lzh2vk30sg00q6v08fd4gdl7mikmz-darkhttpd-1.17/bin/darkhttpd" "/etc/system-services/status-ops" "--port" "8081" "--addr" "127.0.0.1"
Restart=on-failure
RestartSec=5
Type=simple

[Install]
WantedBy=multi-user.target

Read it against the module:

  • ExecStart is process.argv, with every element quoted. The configData directory resolved to /etc/system-services/status-ops.
  • DynamicUser=true and Restart=on-failure came from the guarded systemd block. Our Restart beat the NixOS default (always) without mkForce, because the systemd layer sets Type, Restart, RestartSec and wantedBy with lib.mkDefault.
  • RestartSec=5, Type=simple and WantedBy=multi-user.target are NixOS’s modular-service defaults.

Check the generated file, and smoke-test the server outside systemd:

page=$(nix build --no-link --print-out-paths \
  '.#nixosConfigurations.box.config.environment.etc."system-services/status-ops/index.html".source')
srv=$(nix build --no-link --print-out-paths \
  '.#nixosConfigurations.box.config.system.services.status-ops.statusPage.package')
mkdir -p /tmp/status-ops && install -m 644 "$page" /tmp/status-ops/index.html
"$srv/bin/darkhttpd" /tmp/status-ops --port 18081 --addr 127.0.0.1 >/dev/null & sleep 0.5
curl -s http://127.0.0.1:18081/; kill %1
<h1>maintenance at 22:00 UTC</h1>

Note that you can reach into an instance’s options (system.services.status-ops.statusPage.package) like any other option path. That is handy in tests and nix repl.

Case 4: A package-exported service as a sub-service

Packages can ship their own service module as passthru.services.default. In 26.05 the ones that do are ghostunnel, git-pages, ktls-utils, php and snid (25.11 had only ghostunnel and php). In Case 2, status-public imports ghostunnel as a sub-service to put TLS in front of the page:

out=$(nix build --no-link --print-out-paths \
  '.#nixosConfigurations.box.config.systemd.units."status-public-tls.service".unit')
cat "$out/status-public-tls.service"
[Unit]
After=network.target
Wants=network.target

[Service]
Environment="LOCALE_ARCHIVE=/nix/store/dc427sjs33dci797apv4lvsg1a69szn0-glibc-locales-2.42-100/lib/locale/locale-archive"
Environment="PATH=/nix/store/bhv6xyvqw6gjs3j488gi1z6wdv18bdpa-coreutils-9.11/bin:/nix/store/nl0wq6d4bpjrxmlax141liq0qr9z87gn-findutils-4.10.0/bin:/nix/store/cqhgy07rpxdwk9kd2qlc3cw4s9ajzm9f-gnugrep-3.12/bin:/nix/store/0s17nja4n8b63by0nlg1m31mxn2bq1g1-gnused-4.10/bin:/nix/store/kmzlca218j6j1ar292fxj7707qzypij8-systemd-260.5/bin:/nix/store/bhv6xyvqw6gjs3j488gi1z6wdv18bdpa-coreutils-9.11/sbin:/nix/store/nl0wq6d4bpjrxmlax141liq0qr9z87gn-findutils-4.10.0/sbin:/nix/store/cqhgy07rpxdwk9kd2qlc3cw4s9ajzm9f-gnugrep-3.12/sbin:/nix/store/0s17nja4n8b63by0nlg1m31mxn2bq1g1-gnused-4.10/sbin:/nix/store/kmzlca218j6j1ar292fxj7707qzypij8-systemd-260.5/sbin"
Environment="TZDIR=/nix/store/mkbbamycmw838fwxk21jz0nw3mhnmk16-tzdata-2026c/share/zoneinfo"
AmbientCapabilities=CAP_NET_BIND_SERVICE
DynamicUser=true
ExecStart="/nix/store/70zzr769qsi8jijkwjp2560f59p3fgg3-ghostunnel-1.8.4/bin/ghostunnel" "server" "--listen" "0.0.0.0:8443" "--target" "127.0.0.1:8080" "--disable-authentication" --keystore=${CREDENTIALS_DIRECTORY}/keystore
LoadCredential=keystore:/run/keys/status-public.pem
Restart=always
RestartSec=5
Type=simple

[Install]
WantedBy=multi-user.target

The ghostunnel module did real systemd work: it loads the key through LoadCredential= and appends --keystore=${CREDENTIALS_DIRECTORY}/… unquoted, so systemd expands the variable. That is the mainExecStart pattern from Case 5. Look at what is missing, though: there is no After=status-public.service. “Sub-service” means owned by, not ordered after. The manual says a sub-service relationship “does not automatically create any other relationship between services”. Add the ordering yourself if the proxy should not start before its backend:

# order-fix.nix — make the TLS sub-service start after, and stop with, its parent
let
  flake = builtins.getFlake (toString ./.);
  sys = flake.nixosConfigurations.box.extendModules {
    modules = [{
      system.services.status-public.services.tls.systemd.service = {
        after = [ "status-public.service" ];
        bindsTo = [ "status-public.service" ];
      };
    }];
  };
  unit = sys.config.systemd.services.status-public-tls;
in { inherit (unit) after bindsTo; }
nix eval --impure --json --file order-fix.nix
{"after":["status-public.service","network.target"],"bindsTo":["status-public.service"]}

In a real host, put those two lines directly into the services.tls block in flake.nix. extendModules is used here only so that the example can amend Case 2 without editing it.

Case 5: systemd specifiers and variables (mainExecStart, 26.05+)

process.argv is portable, so NixOS escapes it completely: % becomes %% and $ becomes $$. When you want systemd to substitute something (the unit name %n, a credentials directory, an environment variable), build systemd.mainExecStart from the escaped argv and append the unescaped part:

# trap-escape.nix — what happens to % and $ in process.argv
let
  flake = builtins.getFlake (toString ./.);
  sys = flake.nixosConfigurations.box.extendModules {
    modules = [
      ({ pkgs, ... }: {
        system.services.hello = {
          process.argv = [ "${pkgs.coreutils}/bin/echo" "unit=%n" "home=$HOME" ];
        };
        system.services.hello2 = { config, ... }: {
          process.argv = [ "${pkgs.coreutils}/bin/echo" ];
          systemd.mainExecStart =
            config.systemd.lib.escapeSystemdExecArgs config.process.argv
            + " unit=%n home=$HOME";
        };
      })
    ];
  };
in
map (n: "${n}: ${builtins.head sys.config.systemd.services.${n}.serviceConfig.ExecStart}")
  [ "hello" "hello2" ]
nix eval --impure --json --file trap-escape.nix | python3 -m json.tool
[
    "hello: \"/nix/store/bhv6xyvqw6gjs3j488gi1z6wdv18bdpa-coreutils-9.11/bin/echo\" \"unit=%%n\" \"home=$$HOME\"",
    "hello2: \"/nix/store/bhv6xyvqw6gjs3j488gi1z6wdv18bdpa-coreutils-9.11/bin/echo\" unit=%n home=$HOME"
]

hello will print the literal text unit=%n home=$HOME. In hello2, systemd itself substitutes %n with the unit name (hello2.service) and expands $HOME from the service’s environment when the unit starts. The first form is right for arguments that come from users or settings, because it can never be misinterpreted. Use the second only for the specific parts that need substitution, and only behind the options ? systemd guard, since other service managers have no %n.

The trap

Four ways this goes wrong on a first attempt. All four were reproduced on 26.05.

1. Importing a service module as a NixOS module. Service modules go inside a system.services.<name> slot. Put one in the host’s top-level imports (or modules = [ … ]) and the module system refuses:

# trap-class.nix — importing a service module as if it were a NixOS module
let
  flake = builtins.getFlake (toString ./.);
  pkgs = flake.inputs.nixpkgs.legacyPackages.x86_64-linux;
  sys = flake.nixosConfigurations.box.extendModules {
    modules = [ pkgs.ghostunnel.services.default ];
  };
in
sys.config.system.build.toplevel.drvPath
nix eval --impure --raw --file trap-class.nix 2>&1 | tail -n 6
       error: The module `/nix/store/1ffpr0yhnxfkyj467rjjxfzsmc9564qb-source/pkgs/by-name/gh/ghostunnel/service.nix` (class: "service") cannot be imported into a module evaluation that expects class "nixos".

       Help:
       - Ensure that you are importing the correct module.
       - Verify that the module's `_class`, "service" matches the expected `class` "nixos".
       - If you are using a custom class, make sure it is correctly defined and used consistently across your modules.

That error is _class doing its job. Set _class = "service" in your own service modules too, so the same mistake fails loudly instead of quietly defining nonsense options.

2. Expecting % or $ in process.argv to be expanded. Case 5: they are escaped. Use systemd.mainExecStart (26.05+). On 25.11 that option does not exist, and the workaround is a wrapper script.

3. Overriding the package and getting the original binary. A package-exported module may pin the package it came from in a way that ignores your override. In 26.05, ghostunnel’s passthru.services.default sets ghostunnel.package = ghostunnel; # FIXME: finalAttrs.finalPackage:

# trap-override.nix — a modular service from an overridden package
let
  flake = builtins.getFlake (toString ./.);
  sys = flake.nixosConfigurations.box.extendModules {
    modules = [
      ({ pkgs, ... }:
        let
          patched = pkgs.ghostunnel.overrideAttrs (old: {
            postInstall = (old.postInstall or "") + "\n# local patch\n";
          });
        in {
          system.services.gt = {
            imports = [ patched.services.default ];
            ghostunnel = {
              listen = "0.0.0.0:9443";
              target = "127.0.0.1:9000";
              keystore = "/run/keys/gt.pem";
              disableAuthentication = true;
            };
          };
          environment.etc."patched-ghostunnel".text = "${patched}";
        })
    ];
  };
in {
  patched = sys.config.environment.etc."patched-ghostunnel".text;
  used = sys.config.system.services.gt.ghostunnel.package.outPath;
}
nix eval --impure --json --file trap-override.nix | python3 -m json.tool
{
    "patched": "/nix/store/bxmg1sxzfpbix6qh7fd9j9j4rdm5zig8-ghostunnel-1.8.4",
    "used": "/nix/store/70zzr769qsi8jijkwjp2560f59p3fgg3-ghostunnel-1.8.4"
}

The service imported from patched still runs the unpatched store path. Setting ghostunnel.package = patched; next to the import fails with “defined multiple times while it’s expected to be unique”, because the package’s definition is a normal-priority value, not a default. You need ghostunnel.package = lib.mkForce patched;. With that line (and lib added to the module arguments), the same evaluation reports "used": "/nix/store/bxmg1sxzfpbix6qh7fd9j9j4rdm5zig8-ghostunnel-1.8.4", the patched build. In your own packages, export services with finalAttrs.finalPackage so overrides flow through.

4. Assuming sub-services are ordered. Case 4: status-public-tls has no After= on status-public. Add after/bindsTo (or requires) explicitly when the relationship matters.

Try this

  1. Add a third instance, status-staging, on port 8082, with only two lines of new configuration. Confirm the new unit and /etc entry with the nix eval commands from Case 2.
  2. Give status-page.nix a statusPage.extraArgs list option, append it to process.argv, and pass an argument containing %. Check the generated ExecStart, and explain why it is safe.
  3. Rewrite status-ops to log its unit name at start-up using systemd.mainExecStart and %n. Keep it behind options ? systemd.
  4. Write a NixOS VM test (pkgs.testers.runNixOSTest) that boots a node with status-public and status-ops, waits for both units, and curls ports 8080 and 8081. The nixpkgs contributor guide requires such a test for every modular service in nixpkgs.
  5. Port one of your own single-instance systemd.services.<x> definitions (from systemd via Nix) to a modular service. Which settings stayed portable, and which had to move behind the guard?

Sources