- Nix 100%
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
|
||
|---|---|---|
| examples | ||
| lib | ||
| modules | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
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. enableDefaultPathis defaulted tofalsehere, unlike NixOS. Upstream it appends coreutils, findutils, gnugrep, gnused and systemd to every service'sPATH; that is free on a NixOS host and about a hundred megabytes of squashfs in an image you copy over the network. Setpath = [ … ]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=andLogsDirectory=; systemd creates those on the host and bind-mounts them in.PrivateTmp=covers scratch space. nixos/libis NixOS-internal with no stability guarantee. That is the standing cost of reusing the schema. Pin nixpkgs;nix flake checkbuilds 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.