- Nix 100%
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 |
||
|---|---|---|
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| service.nix | ||
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 NixOSsystemd.servicessyntax, plus aportable.*block describing the image. This is the file you edit.flake.nix— wiresservice.nixtomkPortable.lib.mkPortableand exposes it aspackages.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.logbefore it has parsed the config, and that path does not exist in the image.-eredirects the startup log to stderr, where the journal picks it up.- Scratch paths under
/tmp. The image root is a read-only squashfs.DynamicUser=impliesPrivateTmp=, 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.
webrootis 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.