Skip to main content

The build subsystem

Building an image is the one step that has to touch the build host. It is isolated from the control plane and fenced two ways.

No general shell

The build is invoked as an argument array, not a shell string. The runner execs one program — kixctl-build — directly, with each argument escaped, so kixctl can run only that one program and only with typed arguments. The program validates every input: the git reference must be commit-pinned, the build attribute is character-restricted, and the image kind is drawn from a fixed set (container today; vm is the parameterized seam for the appliance). There is no command string to inject into.

Never as root

The pipeline runs as one unprivileged service user, owns its own Nix cache, and never invokes nix as root — the Nix daemon performs the privileged store writes, and nix build never needs root. This is enforced by construction rather than vigilance: on the appliance it becomes a systemd unit with a fixed user and cache directory, so ownership is correct from first boot. It sits alongside the control plane's own invariant that it cannot escalate its access.

The image base

Every built image imports a locked base module that supplies the defaults a custom image must carry — each discovered against a real cluster, not assumed:

  • systemd-networkd is the sole network manager, so networkd and dhcpcd don't fight over the interface and lose networking.
  • DNS comes from the DHCP lease, with no hardcoded nameserver, so an image works on any network.
  • git is present in every container, and the firewall is on with each app opening only its own port.

Two things are set at launch rather than in the image: security nesting, which a NixOS container requires, and the target member, which a cluster placement requires — the deploy path always sends both.

Applications are compiled reproducibly from their own lockfiles — a Node app with buildNpmPackage reading the integrity hashes already in package-lock.json, a Python app through uv2nix reading uv.lock. Neither needs a human to compute a dependency hash, which is what lets kixctl build whatever you push with no one in the loop. What a deployable repository declares is covered in Your first deploy.