No description
Find a file
Scott ffdbd4c527 Point the mkPortable input at forge.s99.sh
Use the git+https flakeref rather than git+ssh: both resolve to the same
store path, but https needs no key on the cloning machine, which matters for
a template other people run.

Commit the lock too. A template whose whole promise is "clone it and
`nix build` works" should not depend on nixpkgs-unstable still being
compatible on the day someone tries it; `nix flake update` is one command
for anyone who wants newer.

X-model: claude-opus-5
X-harness: claude-code 2.1.259
X-session-id: 9866604e-97cc-4b82-b840-5df6085437c5
2026-09-18 16:48:42 -05:00
.gitignore Initial commit: mkPortable-template skeleton 2026-09-18 16:07:39 -05:00
flake.lock Point the mkPortable input at forge.s99.sh 2026-09-18 16:48:42 -05:00
flake.nix Point the mkPortable input at forge.s99.sh 2026-09-18 16:48:42 -05:00
README.md Point the mkPortable input at forge.s99.sh 2026-09-18 16:48:42 -05:00
service.nix Add an nginx hello-world portable service template 2026-09-18 16:31:29 -05:00

mkPortable-template

A flake template for building a systemd Portable Service with mkPortable.

Out of the box it builds an nginx that serves a hello-world page on port 8080, as a self-contained image you can attach on any modern systemd host — no Nix required on that host.

Use it

$ nix flake init -t git+https://forge.s99.sh/s99/mkPortable-template.git
$ $EDITOR service.nix
$ nix build .#
$ ls -lh result/
-r--r--r-- 1 root root 14M hello-nginx_1.0.raw

Then ship it to, say, an Ubuntu droplet:

$ scp result/hello-nginx_1.0.raw root@droplet:/var/lib/portables/
$ ssh root@droplet
# portablectl attach --now --enable hello-nginx_1.0.raw
# curl localhost:8080

--now starts it immediately, --enable makes it survive a reboot. To take it back off:

# portablectl detach --now hello-nginx_1.0

To try it without touching the host's persistent state, add --runtime: the attachment lives in /run and evaporates on reboot.

What's in here

  • service.nix — the service. Ordinary NixOS systemd.services syntax, plus a portable.* block describing the image. This is the file you edit.
  • flake.nix — wires service.nix to mkPortable.lib.mkPortable and exposes it as packages.default.

Building for the target, not for your laptop

The image contains compiled binaries. Build for the architecture of the machine you will attach it on:

$ nix build .#packages.aarch64-linux.default

Notes on the nginx example

Worth reading before you swap nginx for your own daemon — each of these is a thing the read-only, user-less image environment forces:

  • DynamicUser = true. A portable image cannot create a system user, so the service runs as a transient one the host allocates at attach time. If your daemon needs a persistent uid owning state on disk, that user has to already exist on the host.
  • -e stderr. nginx opens its compiled-in /var/log/nginx/error.log before it has parsed the config, and that path does not exist in the image. -e redirects the startup log to stderr, where the journal picks it up.
  • Scratch paths under /tmp. The image root is a read-only squashfs. DynamicUser= implies PrivateTmp=, so nginx's temp paths point there.
  • Port 8080, not 80. Keeps the service off a privileged port. For port 80, add AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ].
  • The page itself is a store path. webroot is a derivation referenced from the nginx config, so it is pulled into the image closure automatically. That is the general pattern: reference something by store path and it ships.

For persistent state use StateDirectory=; systemd creates it on the host under /var/lib/ and bind-mounts it into the service's namespace, so it survives detach and re-attach.