nbac #
Nix builder on Apple container: an on-demand, idle-shutdown
aarch64-linux (and optionally x86_64-linux, via Rosetta) remote builder
for Nix on macOS, managed by a single Rust CLI and a thin nix-darwin module.
Run nix build for an aarch64-linux derivation and nbac boots a
lightweight VM through Apple's container runtime, builds there, and powers
the VM off again after a few idle minutes. No resident daemon, no published
ports, no manually managed builder.
How it works #
- The nix-darwin module registers a build machine reached through an SSH
ProxyCommandthat runsnbac proxy. - On each connection the proxy inspects the machine once: if it is running the current image generation, it immediately connects over the host-only vmnet network. Otherwise it takes the cold path — build the image, create or recreate the machine, boot, inject SSH keys — and then connects.
- A watchdog inside the guest powers the machine off after a configurable period with no SSH connections.
- The guest image is built locally from a readable Containerfile (Alpine, upstream Nix from the checksum-verified static tarball, s6 as init). Nothing is pulled from a registry.
- The guest's
/nixstore persists across stop/start. The machine is recreated — deleting that store — only when the image generation changes (Containerfile, build context, or baked-in config), with a warning. - Settings that only apply when the machine is created (
cpus,memory,virtualization) are picked up by a launchd agent the module installs: ondarwin-rebuild switchit deletes the machine, and the next build recreates it. Nonbac resetneeded.
See docs/spec.md for the full design and decision log.
Requirements #
- An Apple silicon Mac (
aarch64-darwin) running nix-darwin. - Apple's
container1.2.0 or newer, installed system-wide (Apple's signed pkg or the Homebrew cask) and onPATH.
Setup #
Add nbac to your nix-darwin flake:
{
inputs.nbac.url = "…"; # this repository
# inside your darwinSystem modules:
modules = [
nbac.darwinModules.default
{
services.nbac.enable = true;
}
];
}
After darwin-rebuild switch, run nbac setup once. It verifies the
runtime, generates the builder and host keys, builds the image, creates and
boots the machine, and checks readiness. (Skipping this is fine too — the
first build triggers the same cold path lazily.)
Useful options under services.nbac: machine.{name,cpus,memory},
idle.{enable,timeoutSeconds}, image.{containerfile,buildContext,packages},
stateDir, rosetta.enable, virtualization.enable, home.enable, and
builder scheduling (systems, maxJobs, speedFactor,
supportedFeatures, mandatoryFeatures, protocol).
Changing any option that reaches the machine deletes it on the next
darwin-rebuild switch — the guest /nix store goes with it, and the next
build repopulates it. Scheduling options (maxJobs, speedFactor,
supportedFeatures, …) live entirely on the host and leave the machine
alone.
x86_64-linux via Rosetta #
services.nbac.rosetta.enable (default off) registers the builder for
x86_64-linux too. Apple container attaches Rosetta only to machines
whose platform is linux/amd64, so the option builds the image and creates
the machine as that platform. The kernel is arm64 either way, and the image
keeps its native aarch64 Nix with extra-platforms = x86_64-linux:
aarch64-linux builds run at full native speed, x86_64-linux builds run
through Rosetta's binfmt handler. Rosetta must be installed on the host
(softwareupdate --install-rosetta). Toggling the option changes the image
generation, so the machine is recreated — deleting its guest /nix store —
with the usual warning.
Incompatible with virtualization.enable for now: container (as of 1.2.0)
assumes a custom kernel matches the machine platform and cannot boot an
amd64-platform machine with the (aarch64) KVM kernel; the module asserts
against the combination.
Working inside the guest #
services.nbac.home.enable (default off) mounts your home directory into the
machine read-write and makes nbac ssh log in as you rather than as
builder. Apple container shares host files only as all-or-nothing on the
home directory of the user owning the runtime, and creates a guest account
with that user's uid/gid — which is what makes the mount usable, since the
files carry host ownership. The guest also trusts that account as a Nix user,
so nix build inside the machine behaves normally.
This is for working in the guest — editing a checkout, running a build by
hand. It does not change how the builder sees your files: distributed builds
still run on the dedicated builder account, copy their closure into the
guest store, and build in the sandbox, where the mount is invisible.
Two notes. Toggling the option changes the image generation, so the machine
is recreated — deleting its guest /nix store — with the usual warning. And
symlinks under the mount that point outside your home dangle in the guest, so
~/.nix-profile and result links resolve against the guest's store, not the
host's.
services.nbac.image.packages puts tools on the PATH of those sessions:
services.nbac.image.packages = [
"git"
"ripgrep"
];
Bare names resolve against the nixpkgs revision nbac is locked to; override
that with image.packagesFlake, or give a full installable
("github:helix-editor/helix#helix") to pin one package elsewhere. The image
build installs them inside the guest into /nix/var/nix/profiles/nbac, a
profile of its own beside the one the Nix tarball creates. Distributed builds
never see them — this is for working in the guest. Changing the list changes
the image generation, with the usual machine recreation.
Nested virtualization #
services.nbac.virtualization.enable (default off) exposes /dev/kvm inside
the builder so it can itself run VMs — needed for nixos-test-style
derivations and KVM-accelerated cross builds. It requires Apple silicon M3+
and macOS 15+, and switches the machine to a custom aarch64-linux guest
kernel built with CONFIG_KVM=y (from the checked-in config, the running
builder's kernel config with KVM enabled) that nbac passes to container machine create --virtualization --kernel.
That kernel can only be built on an aarch64-linux builder — nbac itself — so
bring nbac up with virtualization off first, then enable it. Because every
nixpkgs kernel build requires the big-parallel feature, the builder must
advertise it:
services.nbac.supportedFeatures = [
"big-parallel"
];
Add that (and enough machine.{cpus,memory} to compile a kernel) in one
darwin-rebuild switch, then set virtualization.enable = true and rebuild
again; the second rebuild schedules the kernel build on the running builder,
then deletes the machine so the next build recreates it with the new kernel.
Commands #
| Command | Behavior |
|---|---|
nbac setup |
Idempotent preflight: keys, image, machine, boot, readiness. |
nbac status |
Concurrent read-only probes of the runtime, machine, guest sshd, and remote nix daemon; exits non-zero when unhealthy. |
nbac start / nbac stop |
Explicit lifecycle control. |
nbac reset |
Destroy and recreate the machine (confirms; deletes the guest /nix store). |
nbac ssh [args…] |
Interactive SSH into the builder. Guest logs live under /var/log/nbac. |
nbac completions <shell> |
Shell completion scripts. |
nbac proxy also exists, hidden: it is the SSH ProxyCommand everything
else hangs off.
Standalone use #
The module is optional sugar. nbac reads one TOML file
(/etc/nbac/config.toml, or --config <path>):
[image]
containerfile = "/path/to/Containerfile"
[state]
dir = "/Users/you/.local/state/nbac"
[machine]
cpus = 4
memory = "6G"
[idle]
timeout_seconds = 300
image and state are required; everything else has defaults. Custom
images must honor the contract described in the
spec (s6 supervision, an sshd service the
key injection can restart, a supervised nix-daemon, the watchdog).
Security #
- Keys are generated locally under the state directory (mode 0700), never
in the Nix store; the guest host key is pinned via a generated
known_hostswithStrictHostKeyChecking yes. - The guest user has passwordless sudo for exactly one command:
nix-daemon --stdio. - No published ports: the guest sshd is only reachable from the host over the host-only vmnet interface.
Development #
nix develop # rust toolchain, just, nixfmt
nix flake check # build, clippy, rustfmt/nixfmt, module eval
cargo test --test container_service -- --ignored # live container-service E2E test
Inspired by nix-hex-box; the spec records what was kept and what was deliberately rejected.