No description
Find a file
Scott 0e75edfc1e Build portable service images from NixOS-style service definitions
The `systemd.services.<name>` schema and the unit renderer are not part of
the NixOS module that binds services to a NixOS host -- they live in
nixos/lib, and nixos/lib/utils.nix assembles them from a plain
{ lib, config, pkgs }. On the unit-generation path the only `config` those
files read is a handful of config.systemd.* attributes, so lib/systemd-utils.nix
hands them a stub and gets back the real `systemdUtils`.

That leaves nixos/modules/system/boot/systemd.nix out entirely, which is
where the NixOS-host coupling actually lives (upstream units, environment.etc,
switch-to-configuration). modules/portable.nix renders each unit with
serviceToUnit and feeds the files to pkgs.portableService, the squashfs
builder already in nixpkgs.

Two deliberate departures from NixOS defaults:

- `enableDefaultPath` defaults to false. Upstream it appends coreutils,
  findutils, gnugrep, gnused and systemd itself to every service's PATH
  (nixos/lib/systemd-lib.nix:701) -- free on a NixOS host, ~100 MB of
  squashfs in an image you copy over the network. The services type is
  rebuilt exactly as nixos/lib/systemd-types.nix:131 does, with one module
  appended to flip it.
- Units are rendered with writeText rather than systemdUtils.lib.makeUnit,
  because portableService copies each with `cp ${u} .../${u.name}` and so
  needs the derivation *name* to be the unit filename; makeUnit writes a
  directory containing the unit instead.

Unit names are checked against the image prefix at eval time, since systemd
silently declines to activate units from an image that lack it.

X-model: claude-opus-5
X-harness: claude-code 2.1.259
X-session-id: 9866604e-97cc-4b82-b840-5df6085437c5
2026-09-18 16:31:29 -05:00
examples Build portable service images from NixOS-style service definitions 2026-09-18 16:31:29 -05:00
lib Build portable service images from NixOS-style service definitions 2026-09-18 16:31:29 -05:00
modules Build portable service images from NixOS-style service definitions 2026-09-18 16:31:29 -05:00
.gitignore Initial commit: mkPortable library skeleton 2026-09-18 16:07:39 -05:00
flake.lock Build portable service images from NixOS-style service definitions 2026-09-18 16:31:29 -05:00
flake.nix Build portable service images from NixOS-style service definitions 2026-09-18 16:31:29 -05:00
README.md Build portable service images from NixOS-style service definitions 2026-09-18 16:31:29 -05:00

mkPortable

Build systemd Portable Services from NixOS-style service definitions.

You write an ordinary systemd.services.<name> block — the same schema you already know — and nix build gives you a single .raw file. Copy it to any host running systemd ≥ 239 with systemd-portabled, run portablectl attach, and the service runs. That host does not need Nix: the image carries its own /nix/store closure and systemd runs the unit with the image as its root.

$ nix build .#
$ scp result/hello-nginx_1.0.raw root@my-ubuntu-box:/var/lib/portables/
$ ssh root@my-ubuntu-box portablectl attach --now --enable hello-nginx_1.0.raw

Usage

{
  inputs.mkPortable.url = "github:sc0ttV6/mkPortable";

  outputs = { nixpkgs, mkPortable, ... }: {
    packages.x86_64-linux.default = mkPortable.lib.mkPortable {
      pkgs = nixpkgs.legacyPackages.x86_64-linux;
      modules = [ ./service.nix ];
    };
  };
}

service.nix:

{ pkgs, ... }:
{
  portable = {
    name = "hello";
    version = "1.0";
  };

  systemd.services.hello = {
    wantedBy = [ "multi-user.target" ];
    script = "echo hello";
    serviceConfig = {
      Type = "oneshot";
      DynamicUser = true;
    };
  };
}

See mkPortable-template for a worked example that serves a page with nginx.

How it works

The systemd.services.<name> schema and the code that renders a unit file are not part of the NixOS module that binds services to a NixOS host. They live in nixos/lib/, and nixos/lib/utils.nix assembles them from a plain { lib, config, pkgs }. On the unit-generation path the only config those files read is a handful of config.systemd.* attributes, so lib/systemd-utils.nix hands them a stub and gets back the real thing.

What we skip is nixos/modules/system/boot/systemd.nix — upstream units, environment.etc, switch-to-configuration. That module is what ties units to a NixOS host; the schema is not.

From there modules/portable.nix renders each unit with serviceToUnit and hands the files to pkgs.portableService, the builder already in nixpkgs, which produces the squashfs with /etc/os-release and the closure copied in.

Options

Option Description
portable.name Image name, and the mandatory prefix on every unit name. No _.
portable.version Image version; part of its portablectl identity. No _.
portable.description PORTABLE_PRETTY_NAME in /etc/os-release.
portable.homepage HOME_URL in /etc/os-release.
portable.contents Extra store paths to include, with closures.
portable.symlinks { object, symlink } pairs, for software wanting an FHS path.
portable.squashCompression mksquashfs -comp. zstd … builds far faster than the xz default.
systemd.services As NixOS, via nixpkgs' own option types.
systemd.sockets / .timers / .paths / .targets Likewise.
build.image The image derivation.
build.units The rendered unit files, for inspection.

lib.evalPortable returns the whole evalModules result if you want config or options rather than just the image.

Things that will bite you

  • Unit names must start with portable.name. systemd will not activate a unit out of an image whose filename lacks the image's prefix. The module catches this at eval time with a pointed error.
  • enableDefaultPath is defaulted to false here, unlike NixOS. Upstream it appends coreutils, findutils, gnugrep, gnused and systemd to every service's PATH; that is free on a NixOS host and about a hundred megabytes of squashfs in an image you copy over the network. Set path = [ … ] explicitly, or turn it back on per service.
  • There are no users. The image cannot create one. Use DynamicUser = true, or make sure the account already exists on the host.
  • The image root is read-only. Use StateDirectory=, RuntimeDirectory=, CacheDirectory= and LogsDirectory=; systemd creates those on the host and bind-mounts them in. PrivateTmp= covers scratch space.
  • nixos/lib is NixOS-internal with no stability guarantee. That is the standing cost of reusing the schema. Pin nixpkgs; nix flake check builds an image and will tell you when a bump breaks something.

Requirements

Host side: systemd ≥ 239 and systemd-portabled. Debian/Ubuntu ship it in systemd; on some distributions it is a separate systemd-container package.