Before porting: is it already a modular service?¶
Modular Services
are nixpkgs' portable service modules — a module says what to run without
saying who runs it, so one module works under systemd, under finit and under
dinit. dinix implements the interface; see the system.services option.
system.services.tunnel = {
imports = [ pkgs.ghostunnel.services.default ];
ghostunnel.listen = "127.0.0.1:8443";
dinit.service.dinix.critical = true;
};
If a service already ships one, use it and port nothing. pkgs.<name>.services.default
is where it lives. About ten exist today, php among them, and the number
grows.
If none exists, write one. Every service in services/ is a modular service,
and the same file would work under systemd or finit. What the interface cannot
express yet is the part our ports lean on — no user is created, no directory is
made and nothing is owned — so a module that needs those carries a
dinix-specific tree beside the portable half. The interface is new in NixOS
25.11 and changing, so track it rather than wrap it. Issue #15 collects what is
missing.
tests/collections/php-upstream.nix runs nixpkgs' own php-fpm module
unmodified, beside tests/collections/phpfpm.nix, which runs ours. Two things
kept ours: a php.ini of one's own, and a pool socket under the state
directory. A path inside a generated configuration file is fixed when the file
is generated, so it cannot be relocatable.
Porting a service from services-flake or devenv¶
Two libraries of service modules, and the same port works from either:
services-flake,
nix/services/<name>.nix. About thirty services, supervised by process-compose.devenv,
src/modules/services/<name>.nix. About forty, supervised by process-compose as well. Most of the services-flake modules say in their first line that they came from here, so read both where both have the service — devenv usually has more options and services-flake usually has a test.
Ours is dinit, so a port keeps the options and changes the last step.
services/redis.nix and tests/collections/redis.nix are the worked example;
read them beside services-flake/nix/services/redis.nix and the mapping is
visible.
Two things to check before you spend time on a service:
Is the package free, and is it in the cache?
nix eval --file /etc/nixpkgs <pkg>.meta.licenseandnix path-info --store https://cache.nixos.org --closure-size --human-readable "$(nix eval --raw --file /etc/nixpkgs <pkg>)".dev.nixtakes a plainimport <nixpkgs> { }, so an unfree or insecure package needs a caller to relax its configuration and CI cannot run it. Measured:mongodbis unfree (SSPL) andminiois marked insecure.How big is the closure? Every mode boots a guest that unpacks it. A JVM service is gigabytes, and
checksalready builds thirty of these.
A port is two files, and nothing else is edited.
1. The module¶
services/<service>.nix, a modular service:
# Dependencies arrive through importApply, because a modular service takes no
# pkgs argument.
{ redis }:
{ config, options, lib, ... }:
let
cfg = config.redis;
in
{
_class = "service";
options.redis = {
# One for one with the services-flake module.
package = lib.mkOption { type = lib.types.package; default = redis; };
port = lib.mkOption { type = lib.types.port; default = 6379; };
dataDir = lib.mkOption { type = lib.types.str; };
};
config = {
process.argv = [ (lib.getExe' cfg.package "redis-server") "--dir" cfg.dataDir ];
}
// lib.optionalAttrs (options ? dinit) {
redis.dataDir = lib.mkDefault config.dinit.stateDir;
dinit.dirs.${cfg.dataDir}.mode = "0700";
dinit.service.dinix.critical = lib.mkDefault false;
};
}
process.argv is the portable half: what to run, and nothing about who runs
it. Anything dinit-specific goes behind options ? dinit, so the module still
evaluates where dinit is absent — that is how upstream's php module carries its
systemd and finit sections.
Under dinit the module gets stateDir, which is where this service should
keep what it writes, and dirs, mustExist and service — settings written
straight into the dinit service description.
config is lib.mkMerge [ ... ] when both halves set the service's own
option. // is a shallow merge, so a dinit half setting redis.dataDir
replaces a first half setting redis.extraConfig outright, and the setting
vanishes with no error. services/phpfpm.nix shows the merge form.
Which attributes a service defines cannot depend on its own
configuration. lib.optionalAttrs (options ? dinit) is fine: options is
known before config is. lib.optionalAttrs cfg.persistence { dinit = …; }
is an infinite recursion, because the freeform merge has to know the shape
before it can evaluate a value, and the error names _module.freeformType
rather than your line. Keep the shape fixed and gate the value with
lib.mkIf. services/mosquitto.nix shows both.
A file the program reads is a configData entry: the module names the content,
the service manager decides where the file lands, and the module reads the
place back out of configData.<name>.path. See services/nginx.nix.
A process that belongs to another is a sub-service — services.init in
services/postgres.nix — which dinix renders as <service>-<sub>. A
sub-service is ownership and nothing more: it creates no dependency, so state
the order with depends-on.
2. The collection¶
tests/collections/<service>.nix is an ordinary dinix configuration, plus a
collection attribute saying what to ask the running container. One
system.services entry per instance:
{ pkgs, config, lib, ... }:
let
redisService = lib.modules.importApply ../../services/redis.nix {
inherit (pkgs) redis;
};
main = config.system.services.redis-main.redis;
in
{
system.services.redis-main.imports = [ redisService ];
collection = {
writable = [ main.dataDir ];
checks = [
{
name = "redis answers on its TCP port";
command = "${pkgs.redis}/bin/redis-cli -p ${toString main.port} ping";
expect = "PONG";
}
];
};
}
dev.nix finds it by being in that directory. nix build --file ./dev.nix collections.<service>.<mode> runs it in one of four modes — root and
user containers, and vm-root and vm-user without containerization —
and a port passes in all four. Nothing else needs editing, and no Python is
written.
privileged says whether dinit will be root, and a collection reads it
where that changes the configuration — which is run-as and nothing else,
because an unprivileged dinit cannot change user at all. Whether a run is
containerized is the test driver's business, not the configuration's.
A data directory defaults to dinit.stateDir, which is
$DINIX_STATE_DIR/<service>, or /var/lib/<service> when the variable is
unset. The containers run writable, with
a tmpfs for /run and one for the state directory, and dinix-init makes the
data directories on it; the vm modes run no dinix-init at all, so the driver
makes the collection.writable paths on the guest instead.
Uncontainerized never means the host gets trashed: every path a run creates
sits under the state directory.
writable is also the read-only contract: a deployment with a read-only
root mounts exactly these paths — emptyDirs beside /run, which dinit needs
for its control socket everywhere — and nothing else the services write to.
Keeping the list complete is what keeps such a deployment working. The test
itself runs writable; it proves the services start and answer, not that they
declare every write.
A check needs a client, and the image holds only what the services reference.
redis-cli rides along in the redis package; most services are not so
accommodating — memcached ships a server only. collection.packages takes
extra packages for the image, and the check names the client's absolute store
path. A client that needs a pipe — printf 'version\r\n' | nc … — names bash
there too and runs its pipeline through bash -c.
Every collection gets these without asking: the image loads, the container
runs, dinit answers on its control socket, every service reaches started,
no service failed while the checks ran, and SIGTERM stops the container inside
a grace period.
Three things about a check, each measured by getting it wrong:
There is no terminal. A client that formats for a human when it has one prints its raw reply here.
redis-cli existsgives0, not(integer) 0. Expect the raw form.expectis a substring, so pick one that cannot match by accident. Two instances holding different values under the same key prove they are separate; a count of0proves nothing, because0is in10as well.A check cannot name a store path the configuration does not reference. A container holds the image's closure and nothing else, so a
builtins.toFilethat only a check mentions is absent there and present in the vm modes — two modes pass and two fail. Either reference it from the configuration, as the caddy collection's document root is referenced from its Caddyfile, or build the data in the check, as the mailpit collection pipes a message throughprintf.
The mapping¶
services-flake |
dinix |
|---|---|
|
|
|
|
a start script that |
|
|
|
a generated config file |
|
an exported variable |
|
a |
|
|
|
|
a |
|
|
|
nothing; dinit has no namespaces |
Six things change on the way across, and they are the whole of the work:
A state path goes on the command line, never inside a config file.
dataDiris the literal string${DINIX_STATE_DIR:-/var/lib}/<service>, and dinit expands it when it loads the service — which is what lets one store path run in a container, uncontained and under systemd. dinit substitutescommand,stop-command,working-dir,env-file,pid-file,logfileandsocket-listen, and nothing else. A config file is read by the program, which knows nothing of this and would open a directory literally called${DINIX_STATE_DIR:-/var/lib}.Every service has a way out, and it is the one services-flake already uses: a flag (
redis-server --dir) or a prefix (nginx -p,php-fpm -p) with the paths in the config file left relative. Check what else the prefix moves:nginx -palso moves the default document root, so the port namesrootexplicitly — found by a 404 rather than by reading.envis the way out when there is no flag. It is a program, so it goes inprocess.argvand every service manager substitutes it, and it execs, so nothing extra stays running. Three ports need it, each for a different reason:an environment variable — Caddy takes its state directory from
XDG_DATA_HOMEandXDG_CONFIG_HOMEand from no argument at all;a
PATH—mariadb-install-dbis a shell script that callssed, and an image of store paths has noPATH. It exits 1 atsed: command not found.
A working directory is
dinit.service.working-dir, notenv --chdir. mosquitto takespersistence_locationin its configuration file and nowhere else, and writes to the working directory when it is unset. dinit substitutesworking-dir, so the state directory reaches it. The build-time check makesdirsin a state directory of its own before it runs, because dinit-check exits 1 on a working directory that is absent and a directory dinix-init makes at startup always is — seecheckCommandinoptions.nix. Adirsentry outside the state directory is still absent there: a Nix sandbox refusesmkdir /var.No shell. services-flake wraps most services in a
writeShellApplicationto make a directory and export a variable. dinix makes directories withdirsbefore any service starts, and sets variables inenv-file, soprocess.argvis the program itself. A port that still needs a wrapper has found something worth saying out loud.The data directory is declared, not a subdirectory of wherever the developer ran the command. Absolute paths throughout, and the collection names every path that has to be writable, so a read-only deployment knows what to mount. dinix-init makes them from
dirsbefore anything starts, which is what still puts the modes and owners under test.No readiness probes. process-compose polls a service until it answers; dinit does not. Where services-flake gates a dependent on
condition = "process_healthy", dinix hasdepends-on, which waits for the process to start and no more. A service that must not start before another is ready needs that readiness expressing some other way — usually the program's own flag, sometimes ascriptedservice that blocks.The container runs as root, and some services refuse that. memcached does, and so does postgres's
initdb. dinit'srun-aschanges user before exec, so the process never sees root: the source module's privilege story is the collection's to tell, andnobodyis in the user database dinix writes.run-asis not always the answer. A program that drops privilege itself wants root to begin with, and taking it away first breaks it. php-fpm opens its error log by path —/proc/self/fd/2, which is dinit's own log — and a file dinit made as root refuses the account the service would become:failed to open error_log, exit 78. Let such a program keep root and use its own user directive, as memcached's-uand php-fpm'suserdo.Naming the account it already is makes one description work everywhere. A program that drops privilege only does so as root, so
rootin its own user directive is a no-op for anyone else, and no mode needs a branch.mariadbd --user=rootis the clear case: without it, root gets "Please consult the Knowledge Base to find out how to run mysqld as root!" and an exit, and anyone else gets a warning and a running server. mosquitto is the quiet one: withoutuser rootit drops tonobodyand then cannot write into the directory root made, while still running and answering.Secrets and passwords are mounted, never rendered into the store. Everything dinix generates is world-readable. See the host key note in README.md.
Initialising a data directory once¶
initdb exits 1 on a directory that is not empty — measured — so "initialise
if it has not been initialised" is a conditional. PostgreSQL, MySQL and
MongoDB all want one, and a shell is the usual answer.
Use dinix-unless instead, which is unlessPackage:
services.init.process.argv = [
(lib.getExe dinix-unless)
"${cfg.pgData}/PG_VERSION" # the marker
(lib.getExe' cfg.package "initdb") # run only if it is absent
"--pgdata" cfg.pgData
];
services.init.dinit.service.type = "scripted";
dinit.service.depends-on = [ "${name}-init" ];
It execs the program when the marker is missing and exits 0 when it is there, so the dependency stays a real one: a first run that genuinely fails still stops the service that needs it.
It is not a small shell — no PATH search, no expansion, no interpretation —
and it is a separate binary from dinix-init, which is in every image and
must never be able to run anything. This one reaches only the closures that
name it.
Licensing¶
services-flake is MIT and devenv is Apache-2.0. A ported module keeps a comment at the top naming the file it came from, and its licence.