Modular Services
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:
importApplyfor dependencies. The file is a function that takes its packages ({ darkhttpd }:) and returns the module. The module itself never seespkgs, because a service manager other than NixOS might not have one.configDatainstead ofpkgs.writeTextplus a hard-coded path. The service asks for a file, and the manager decides where it lives.config.configData."index.html".pathis how the module finds out.lib.optionalAttrs (options ? systemd)instead ofmkIf.mkIfwould still need thesystemdoption 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)'; echonix (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:
ExecStartisprocess.argv, with every element quoted. TheconfigDatadirectory resolved to/etc/system-services/status-ops.DynamicUser=trueandRestart=on-failurecame from the guarded systemd block. OurRestartbeat the NixOS default (always) withoutmkForce, because the systemd layer setsType,Restart,RestartSecandwantedBywithlib.mkDefault.RestartSec=5,Type=simpleandWantedBy=multi-user.targetare 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.drvPathnix 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
- Add a third instance,
status-staging, on port 8082, with only two lines of new configuration. Confirm the new unit and/etcentry with thenix evalcommands from Case 2. - Give
status-page.nixastatusPage.extraArgslist option, append it toprocess.argv, and pass an argument containing%. Check the generatedExecStart, and explain why it is safe. - Rewrite
status-opsto log its unit name at start-up usingsystemd.mainExecStartand%n. Keep it behindoptions ? systemd. - Write a NixOS VM test (
pkgs.testers.runNixOSTest) that boots a node withstatus-publicandstatus-ops, waits for both units, andcurls ports 8080 and 8081. The nixpkgs contributor guide requires such a test for every modular service in nixpkgs. - 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
- NixOS manual (26.05), Modular Services: status, portability, composition and ownership, the portable and systemd-specific option references: https://nixos.org/manual/nixos/stable/#modular-services
- nixpkgs contributor guide,
nixos/README-modular-services.md(VM test,meta.maintainers,options ? systemd,_class,finalAttrs.finalPackage): https://github.com/NixOS/nixpkgs/blob/nixos-26.05/nixos/README-modular-services.md - Portable option declarations: https://github.com/NixOS/nixpkgs/blob/nixos-26.05/lib/services/service.nix; systemd layer: https://github.com/NixOS/nixpkgs/blob/nixos-26.05/nixos/modules/system/service/systemd/service.nix
- Original design and feedback thread, NixOS/nixpkgs PR #372170: https://github.com/NixOS/nixpkgs/pull/372170
ghostunnelservice module and package export (26.05): https://github.com/NixOS/nixpkgs/blob/nixos-26.05/pkgs/by-name/gh/ghostunnel/service.nix- systemd:
systemd.service(5)Command lines andsystemd.unit(5)Specifiers: https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html