modules: add modular services support

Adds `home.services`, an attribute set of nixpkgs
[modular services](https://nixos.org/manual/nixos/unstable/#modular-services)
sourced from `<nixpkgs/lib/services/lib.nix>`. Each service exposes
`process.argv` and the upstream NixOS-style systemd schema
(`systemd.lib`, `systemd.mainExecStart`, `systemd.service`,
`systemd.services`, `systemd.sockets`) by re-exporting
`nixos/modules/system/service/systemd/service.nix`. Service modules
shipped with `_class = "service"` (e.g.
`pkgs.<name>.passthru.services.default`) drop in unchanged --
service portability across module systems is the point of modular
services.

Lifted units are evaluated and translated from NixOS-style attrs
(`wantedBy`, `serviceConfig`, `unitConfig`, `environment`, ...) into
the section-based INI shape (`{ Unit; Service; Install; }`) that
home-manager's `systemd.user.{services,sockets}` consumes; only the
common keys are mapped, uncommon options remain reachable via
`unitConfig` / `serviceConfig` / `socketConfig`. Sub-services and
their units are dashed under the parent service name;
`process.argv` becomes the default `ExecStart` for the service's
primary unit, which defaults to `WantedBy=default.target`.

Mirrors the surface of nixpkgs' portable systemd module (services +
sockets only); other unit kinds home-manager supports natively
(timers etc.) are intentionally not modeled until upstream grows
them.

Each service's `configData.<name>` entries are materialized at
`$XDG_CONFIG_HOME/system-services/<service-prefix>/<name>` (mirroring
how `nixos/modules/system/service/systemd/{config-data-path,system}.nix`
lifts `configData` to `environment.etc`), with the absolute path
injected back into `configData.<name>.path` so the service can refer
to its files at a stable location.

Includes nmt tests covering: a basic `process.argv`-only service, a
service with a `configData` entry, and importing
`pkgs.ghostunnel.passthru.services.default` to assert the lifted user
unit contains the expected ExecStart flags and `LoadCredential`
entries.
This commit is contained in:
cinereal
2026-05-01 10:44:56 +02:00
committed by Matthieu Coudron
parent 9c6f1307e1
commit fb6a0c6d39
13 changed files with 404 additions and 0 deletions

View File

@@ -60,6 +60,7 @@ usage/rollbacks.md
usage/dotfiles.md
usage/graphical.md
usage/gpu-non-nixos.md
usage/modular-services.md
usage/updating.md
usage/upgrading.md
```

View File

@@ -0,0 +1,77 @@
# Modular Services {#sec-usage-modular-services}
Home Manager supports nixpkgs
[modular services](https://nixos.org/manual/nixos/unstable/#modular-services)
under [](#opt-home.services). This is the home-manager analog to the
NixOS `system.services` namespace: each entry is an abstract service
sourced from `<nixpkgs/lib/services/lib.nix>` with the upstream portable
systemd module loaded into it, so service modules shipped with packages
(e.g. `pkgs.<name>.passthru.services.default`) drop in unchanged --
the same module evaluates on NixOS and on Home Manager.
A minimal example -- run a one-shot user service from a package's
modular service definition:
```nix
{ pkgs, ... }: {
home.services.tunnel = {
imports = [ pkgs.ghostunnel.passthru.services.default ];
ghostunnel = {
listen = "127.0.0.1:8443";
target = "127.0.0.1:8080";
cert = "/run/secrets/cert.pem";
key = "/run/secrets/key.pem";
allowAll = true;
};
};
}
```
This produces `~/.config/systemd/user/tunnel.service` with the expected
`ExecStart`, `LoadCredential`, and `WantedBy=default.target`.
Each service exposes the upstream NixOS-style schema: [`process.argv`],
`systemd.lib`, `systemd.mainExecStart`, `systemd.service`,
`systemd.services`, `systemd.sockets`. Lifted units are translated from
NixOS-style attrs (`wantedBy`, `serviceConfig`, `unitConfig`,
`environment`, ...) into the section-based INI shape
(`{ Unit; Service; Install; }`) that home-manager's
[](#opt-systemd.user.services) consumes. Only common keys are mapped
explicitly; uncommon options remain reachable via `unitConfig`,
`serviceConfig`, or `socketConfig`.
Sub-services (nested `services.<sub>` inside another service) and their
units are dashed under the parent service name. The empty unit key
`""` denotes the service's *primary* unit (lifted to a unit named
after the service itself); [`process.argv`] becomes the default
`ExecStart` for that unit, which defaults to `WantedBy=default.target`.
## Configuration data {#sec-usage-modular-services-configdata}
Each service can declare configuration files via `configData.<name>`.
These are materialized at `$XDG_CONFIG_HOME/system-services/<service>/<name>`
(mirroring how NixOS lifts `configData` to `environment.etc`), with the
absolute path injected back into `configData.<name>.path` so the service
can refer to its files at a stable location:
```nix
{ config, ... }:
{
home.services.demo = {
process.argv = [ "/bin/myapp" "--config" config.home.services.demo.configData."app.toml".path ];
configData."app.toml".text = ''
port = 1234
'';
};
}
```
## Scope notes {#sec-usage-modular-services-scope}
Home Manager mirrors the surface of nixpkgs' portable systemd module:
services and sockets only. Other unit kinds Home Manager supports
natively under [](#opt-systemd.user.services) (timers, paths, mounts, ...)
are intentionally not modeled on `home.services` until upstream grows them,
to keep both surfaces aligned.
[`process.argv`]: https://nixos.org/manual/nixos/unstable/#opt-system.services._name_.process.argv

View File

@@ -19,6 +19,13 @@ This release has the following notable changes:
range 1.02.0, previously it erroneously expected values in the
range `0.01.0`.
- New [](#opt-home.services) namespace for nixpkgs
[modular services](https://nixos.org/manual/nixos/unstable/#modular-services).
Service modules shipped with packages (e.g.
`pkgs.<name>.passthru.services.default`) drop in unchanged and are
lifted to user systemd units. See
[Modular Services](#sec-usage-modular-services) for details.
## State Version Changes {#sec-release-26.05-state-version-changes}
The state version in this release includes the changes below. These

View File

@@ -0,0 +1,11 @@
{
time = "2026-04-29T15:39:41+00:00";
condition = true;
message = ''
A new `home.services` namespace has been added for nixpkgs
modular services. Service modules shipped with packages (i.e.
`pkgs.<name>.passthru.services.default`) drop in unchanged and are
lifted to user systemd units. See the "Modular Services" chapter
in the manual for details.
'';
}

View File

@@ -64,6 +64,7 @@ let
./misc/xdg-user-dirs.nix
./misc/xdg.nix
./misc/xfconf.nix
./services-modular
./systemd.nix
./targets/darwin
./targets/generic-linux.nix

View File

@@ -0,0 +1,42 @@
# Sets `path` on each modular service's `configData.<name>` so that the
# service can refer to its config files at a stable absolute location
# inside the user's XDG config directory. Mirrors
# `nixos/modules/system/service/systemd/config-data-path.nix`.
let
setPathsModule =
prefix:
{
lib,
name,
xdgConfigHome,
...
}:
let
inherit (lib) mkOption types;
servicePrefix = "${prefix}${name}";
in
{
_class = "service";
options = {
configData = mkOption {
type = types.lazyAttrsOf (
types.submodule (
{ config, ... }:
{
config.path = lib.mkDefault "${xdgConfigHome}/system-services/${servicePrefix}/${config.name}";
}
)
);
};
services = mkOption {
type = types.attrsOf (
types.submoduleWith {
modules = [ (setPathsModule "${servicePrefix}-") ];
specialArgs = { inherit xdgConfigHome; };
}
);
};
};
};
in
setPathsModule ""

View File

@@ -0,0 +1,187 @@
{
lib,
config,
pkgs,
...
}:
let
inherit (lib) concatMapAttrs mkOption types;
portable-lib = import (pkgs.path + "/lib/services/lib.nix") { inherit lib; };
# Combine a service-name prefix with a unit name. The empty unit name
# `""` is the convention from nixpkgs' portable services for the
# service's *primary* unit (e.g. `services.foo.systemd.user.services.""`
# is the unit named `foo.service`). Sub-units use a hyphenated suffix.
dashed =
before: after:
if after == "" then
before
else if before == "" then
after
else
"${before}-${after}";
# Translate a NixOS-style systemd unit attrset (wantedBy, serviceConfig,
# unitConfig, environment, ...) into the section-based INI shape that
# home-manager's `systemd.user.<unitType>` expects (Unit/Service/Install).
# Only the common keys are mapped; uncommon options can still be set
# explicitly via `unitConfig` / `serviceConfig` / `socketConfig`.
unitAttrKeys = [
"description"
"documentation"
"requires"
"wants"
"upholds"
"after"
"before"
"bindsTo"
"partOf"
"conflicts"
"requisite"
"onFailure"
"onSuccess"
];
pickSection =
keys: src:
lib.listToAttrs (
lib.concatMap (
k:
lib.optional (src ? ${k} && src.${k} != null && src.${k} != [ ]) {
name = lib.toSentenceCase k;
value = src.${k};
}
) keys
);
envToList =
env: lib.mapAttrsToList (k: v: "${k}=${toString v}") (lib.filterAttrs (_: v: v != null) env);
installSection =
u:
lib.filterAttrs (_: v: v != [ ]) {
WantedBy = u.wantedBy or [ ];
RequiredBy = u.requiredBy or [ ];
};
toHmIni = unit: {
Unit = pickSection unitAttrKeys unit // (unit.unitConfig or { });
Service =
(unit.serviceConfig or { })
// lib.optionalAttrs (unit ? environment && unit.environment != { }) {
Environment = envToList unit.environment;
};
Install = installSection unit;
};
toHmIniSocket = sock: {
Unit = pickSection unitAttrKeys sock // (sock.unitConfig or { });
Socket =
(sock.socketConfig or { })
// lib.optionalAttrs (sock ? listenStreams && sock.listenStreams != [ ]) {
ListenStream = sock.listenStreams;
}
// lib.optionalAttrs (sock ? listenDatagrams && sock.listenDatagrams != [ ]) {
ListenDatagram = sock.listenDatagrams;
};
Install = installSection sock;
};
# Evaluate a deferredModule into attrs, then translate.
evalDeferred =
translator: unitModule:
translator
(lib.evalModules {
modules = [
(_: {
# Loose schema: NixOS-style unit attrs include nested sub-attrsets
# (e.g. `serviceConfig`) that need to merge across definitions.
freeformType =
with types;
attrsOf (oneOf [
(attrsOf raw)
(listOf raw)
raw
]);
})
unitModule
];
}).config;
makeUnits =
translator: unitType: prefix: service:
concatMapAttrs (unitName: unitModule: {
"${dashed prefix unitName}" = evalDeferred translator unitModule;
}) service.systemd.${unitType}
// concatMapAttrs (
subName: subService: makeUnits translator unitType (dashed prefix subName) subService
) service.services;
# Lift each service's `configData` entries into `xdg.configFile` paths.
# Mirrors how `nixos/modules/system/service/systemd/system.nix` lifts
# `configData` to `environment.etc`.
makeConfigFiles =
prefix: service:
lib.mapAttrs' (_: cfg: {
name = "system-services/${prefix}/${cfg.name}";
value = lib.filterAttrs (_: v: v != null) {
source = cfg.source or null;
text = cfg.text or null;
inherit (cfg) enable;
};
}) (lib.filterAttrs (_: cfg: cfg.enable) (service.configData or { }))
// concatMapAttrs (
subName: subService: makeConfigFiles (dashed prefix subName) subService
) service.services;
modularServiceConfiguration = portable-lib.configure {
serviceManagerPkgs = pkgs;
extraRootModules = [
./service.nix
./config-data-path.nix
];
extraRootSpecialArgs = {
systemdPackage = pkgs.systemd;
nixpkgsPath = pkgs.path;
xdgConfigHome = config.xdg.configHome;
};
};
in
{
meta.maintainers = [ lib.maintainers.kiara ];
options.home.services = mkOption {
description = ''
Home-Manager [modular services](https://nixos.org/manual/nixos/unstable/#modular-services).
Each entry is an abstract service that may declare a {option}`process.argv`
and home-manager-style {option}`systemd.user.{services,sockets}` units
(INI section shape). Units are emitted under home-manager's
{option}`systemd.user.services` (and friends) with the service name
as a prefix. Mirrors {option}`system.services` in NixOS.
'';
type = types.attrsOf modularServiceConfiguration.serviceSubmodule;
default = { };
visible = "shallow";
};
config = {
assertions = lib.concatLists (
lib.mapAttrsToList (
name: cfg: portable-lib.getAssertions [ "home" "services" name ] cfg
) config.home.services
);
warnings = lib.concatLists (
lib.mapAttrsToList (
name: cfg: portable-lib.getWarnings [ "home" "services" name ] cfg
) config.home.services
);
systemd.user.services = concatMapAttrs (
name: svc: makeUnits toHmIni "services" name svc
) config.home.services;
systemd.user.sockets = concatMapAttrs (
name: svc: makeUnits toHmIniSocket "sockets" name svc
) config.home.services;
xdg.configFile = concatMapAttrs (name: svc: makeConfigFiles name svc) config.home.services;
};
}

View File

@@ -0,0 +1,22 @@
# Per-service extension module loaded into every `home.services` entry.
#
# Re-exports the nixpkgs portable systemd service module so HM modular
# services accept the same NixOS-style schema (`systemd.lib`,
# `systemd.mainExecStart`, `systemd.service`, `systemd.services`,
# `systemd.sockets`) as their NixOS counterparts. This is what lets
# upstream service modules (e.g. `pkgs.<name>.passthru.services.default`)
# drop in unchanged. Then overrides the primary unit's `wantedBy` default
# to `default.target`, since user units typically attach to that instead
# of `multi-user.target`.
{ lib, nixpkgsPath, ... }:
{
imports = [
(nixpkgsPath + "/nixos/modules/system/service/systemd/service.nix")
];
# The empty key `""` is the modular service's *primary* unit (see
# `dashed` in `default.nix`).
config.systemd.services."" = {
wantedBy = lib.mkOverride 950 [ "default.target" ];
};
}

View File

@@ -212,6 +212,7 @@ import nmtSrc {
./modules/misc/qt
./modules/misc/xdg/linux.nix
./modules/misc/xsession
./modules/services-modular
./modules/systemd
./modules/targets-linux
# keep-sorted end

View File

@@ -0,0 +1,15 @@
{ pkgs, ... }:
{
home.services.demo = {
process.argv = [
"${pkgs.coreutils}/bin/echo"
"hello"
];
};
nmt.script = ''
assertFileExists home-files/.config/systemd/user/demo.service
assertFileContains home-files/.config/systemd/user/demo.service '/bin/echo'
assertFileContains home-files/.config/systemd/user/demo.service 'WantedBy=default.target'
'';
}

View File

@@ -0,0 +1,15 @@
{ pkgs, ... }:
{
home.services.demo = {
process.argv = [ "${pkgs.coreutils}/bin/true" ];
configData."config.toml".text = ''
[server]
port = 1234
'';
};
nmt.script = ''
assertFileExists home-files/.config/system-services/demo/config.toml
assertFileContains home-files/.config/system-services/demo/config.toml 'port = 1234'
'';
}

View File

@@ -0,0 +1,5 @@
{
home-services-basic = ./basic.nix;
home-services-configdata = ./configdata.nix;
home-services-ghostunnel = ./ghostunnel.nix;
}

View File

@@ -0,0 +1,20 @@
{ pkgs, ... }:
{
home.services.tunnel = {
imports = [ pkgs.ghostunnel.passthru.services.default ];
ghostunnel = {
listen = "127.0.0.1:8443";
target = "127.0.0.1:8080";
cert = "/run/secrets/cert.pem";
key = "/run/secrets/key.pem";
allowAll = true;
};
};
nmt.script = ''
assertFileExists home-files/.config/systemd/user/tunnel.service
assertFileContains home-files/.config/systemd/user/tunnel.service '/bin/ghostunnel'
assertFileContains home-files/.config/systemd/user/tunnel.service 'allow-all'
assertFileContains home-files/.config/systemd/user/tunnel.service 'LoadCredential=cert:/run/secrets/cert.pem'
'';
}