# Before porting: is it already a modular service? [Modular Services](https://nixos.org/manual/nixos/unstable/#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. ```nix 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..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](https://github.com/juspay/services-flake), `nix/services/.nix`. About thirty services, supervised by process-compose. - [devenv](https://github.com/cachix/devenv), `src/modules/services/.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 .meta.license` and `nix path-info --store https://cache.nixos.org --closure-size --human-readable "$(nix eval --raw --file /etc/nixpkgs )"`. `dev.nix` takes a plain `import { }`, so an unfree or insecure package needs a caller to relax its configuration and CI cannot run it. Measured: `mongodb` is unfree (SSPL) and `minio` is marked insecure. - **How big is the closure?** Every mode boots a guest that unpacks it. A JVM service is gigabytes, and `checks` already builds thirty of these. A port is two files, and nothing else is edited. ## 1. The module `services/.nix`, a modular service: ```nix # 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..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 `-`. A sub-service is ownership and nothing more: it creates no dependency, so state the order with `depends-on`. ## 2. The collection `tests/collections/.nix` is an ordinary dinix configuration, plus a `collection` attribute saying what to ask the running container. One `system.services` entry per instance: ```nix { 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..` 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/`, or `/var/lib/` 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 exists` gives `0`, not `(integer) 0`. Expect the raw form. - **`expect` is 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 of `0` proves nothing, because `0` is in `10` as 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.toFile` that 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 through `printf`. ## The mapping | services-flake | dinix | | --- | --- | | `services..` | `system.services.-` | | `dataDir`, relative to the project | `dataDir`, an absolute path in a container | | a start script that `mkdir -p`s `dataDir` | `dinit.dirs.` | | `command =