What Is Nixinate? Hermetic NixOS Deployment from a Flake
Status: Part 1 of 2 — What is Nixinate? + History Date: 2026-07-19 Series: Nix Patterns Tags: NixOS, Deployment, Infrastructure, DevOps, Flakes Author: John Bargman
TL;DR
nix run .#machineName deploys a NixOS configuration to a
remote machine. The deployment script is generated from your flake’s
nixosConfigurations. It supports hermetic mode (pinned
nixpkgs for deterministic builds), incremental migration (stepwise
stateful upgrades), leapfrog upgrades (ancient NixOS directly to
latest), image generation (raw/ISO/installer), and deployment locking
(atomic mkdir). Just bash and Nix.
The Problem
For years, NixOS deployment was a game of choosing between bad options: use NixOps and inherit its complexity, use Colmena and deal with a Ruby runtime, or do it yourself with SSH and hope. The situation felt fundamentally wrong.
What we needed was something that: - Generates deployment scripts
directly from your flake’s nixosConfigurations - Supports
hermetic mode (pinned nixpkgs for deterministic, reproducible
deployments) - Handles the weird cases — upgrading ancient NixOS
systems, cross-architecture deployments, offline provisioning - Prevents
concurrent deployments to the same machine (atomic locking) - Makes
visible progress at every stage (no silent hangs, no mystery failures) -
Uses only bash and Nix — no external runtimes
Nixinate is that tool. It started as a proof-of-concept abandoned by its original author, Matthew Croughan, but over the last three years it’s evolved into a production-grade deployment engine used daily to manage real systems. Now we’re taking it further: this is the NixOS community’s canonical deployment tool.
The Philosophy
Nixinate follows a core design philosophy I call “just bash and nix”:
- Generated, not interpreted: The deployment script is plain bash, generated from pure Nix evaluation. There’s no daemon, no agent, no runtime waiting for commands. It’s a script you can read, understand, and modify if needed.
- Strict deployment matrix: Nixinate offers a limited, well-defined set of deployment methods — local, remote, hermetic, and planned incubation. Each method is functional, declarative, and reliable. Users don’t pick from twenty options; they pick from a few that always work.
- No timeouts, no masking: Stalls are errors. Every phase produces visible output with timestamps. If something hangs, the user sees it immediately and decides what to do. No silent failures, no ambiguous partial states.
- Deployed or not deployed: A deployment either succeeded or it failed. There is no middle ground.
The Core Pattern: Generated Deployment Scripts
At the heart of Nixinate is a simple idea: every machine in your
nixosConfigurations gets its own self-contained bash script
that you invoke with nix run .#machineName.
Here’s how you set it up. First, add nixinate to your flake inputs:
{
inputs = {
nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0";
nixinate = {
url = "github:Bargman-Tech/nixinate";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, nixinate }: {
apps = nixinate.lib.genDeploy.x86_64-linux self;
nixosConfigurations.myMachine = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
./configuration.nix
{
_module.args.nixinate = {
host = "10.0.0.1";
sshUser = "deploy";
};
}
];
};
};
}Then deploy:
nix run .#myMachineBy default, this runs test mode — it builds and
activates the configuration without persisting across reboot. To switch
permanently:
nix run .#myMachine -- switchThe generated script does three main things:
- Builds the system closure — locally (default) or remotely
- Copies it to the target via SSH
- Activates it via
nixos-rebuild switch
At each step, you see timestamped phase markers telling you exactly where the deployment is. If a phase hangs, you know it immediately. If a copy is interrupted, you can re-run the script and it resumes from where it left off.
Build Modes: Where Does The Compilation Happen?
Nixinate gives you two choices for where to build the system closure.
Local Build (Default)
buildOn = "local"
This builds the closure on your machine, then copies it to the remote. It’s best when: - Your deployer machine is powerful - The target is resource-constrained - Network bandwidth to the target is good
The pipeline looks like this:
[PRE-COPY START] Building and copying system closure to remote
$ nix build --print-out-paths <flake>#nixosConfigurations.myMachine.config.system.build.toplevel
$ nix copy <closure> --to ssh://user@host
[PRE-COPY END]
[DEPLOY START] Activating configuration on remote
$ nixos-rebuild test --flake <flake> --target-host user@host --sudo
[DEPLOY END]
Remote Build
buildOn = "remote"
This copies the flake source to the remote and builds everything there. It’s best when: - The target machine is powerful - Network bandwidth to the target is limited - You want the target to evaluate with its own installed Nix version
The pipeline is different:
[COPY START] Sending flake to remote
$ nix copy <flake> --to ssh://user@host
[COPY END]
[CLOSE COPY START] Copying nixos-rebuild and tool derivations
$ nix copy --derivation nixos-rebuild --to ssh://user@host
[CLOSE COPY END]
[ACTIVATION START] Building and activating on remote
$ ssh user@host "sudo nixos-rebuild test --flake <flake>"
[ACTIVATION END]
Each mode has trade-offs. Local build is simpler and gives you better control — you know exactly what’s being deployed because you built it. Remote build is useful when the target is more powerful than your deployer, or when network is the bottleneck.
Hermetic Mode: Pinned Tooling for Deterministic Deployment
Here’s where Nixinate gets interesting. NixOS systems are typically
managed by a nix-daemon running on the target. When you run
nixos-rebuild on a remote machine, it uses whatever
nix and nixos-rebuild are installed there.
This is a problem. If your deployer is running Nix 2.25 and the remote target is running Nix 2.11, evaluation differences can cause mysterious failures. Worse, if you’re trying to deploy to an old NixOS system (say, 21.11) from a modern flake, the target’s ancient Nix might not even understand flakes at all.
Hermetic mode solves this by copying your Nix binary and rebuild
engine to the target before running nixos-rebuild. The
remote evaluates your flake with the same tools you’re running,
regardless of what’s installed on the target. This ensures
deterministic, reproducible evaluation everywhere.
By default, hermetic mode is enabled for same-architecture deployments:
_module.args.nixinate = {
host = "10.0.0.1";
sshUser = "deploy";
hermetic = true; # default
};You can also disable it:
_module.args.nixinate = {
host = "10.0.0.1";
sshUser = "deploy";
hermetic = false;
};But hermetic mode is more powerful than a simple boolean. In the flake, you’ll see this normalization logic:
rawHermetic = parameters.hermetic or true;
hermeticConfig =
if isCrossArch then { enable = false; }
else if builtins.isBool rawHermetic then { enable = rawHermetic; }
else if builtins.isAttrs rawHermetic then rawHermetic // { enable = rawHermetic.enable or true; }
else builtins.abort "nixinate.hermetic must be a bool or a set";This is the key innovation: hermetic can be a boolean
(for backward compatibility), or it can be a set with tool
selection. This enables powerful deployment patterns.
The Two Deployment Patterns Enabled by Hermetic Tool Selection
By letting users specify exactly which nixos-rebuild and
nix binaries ship to the target, Nixinate enables two
critical workflows that were previously impossible.
Pattern 1: Incremental Migration
When upgrading a stateful system (database server, persistent state, etc.), you can’t jump from NixOS 21.11 to the latest unstable in one step — you risk data loss. Incremental migration pins the hermetic payload to an older nixpkgs matching the target’s current version:
oldPkgs = import nixpkgs {
system = "x86_64-linux";
rev = "21.11"; # match target's current nixpkgs
};
_module.args.nixinate = {
host = "10.0.0.1";
sshUser = "deploy";
hermetic = {
enable = true;
nixos-rebuild = oldPkgs.nixos-rebuild;
};
};Deploy with nix run .#myMachine -- switch. The remote
runs nixos-rebuild using the same nixpkgs
the target is currently running. State migrates safely. Then repeat the
process with the next NixOS version. One step at a time, you’re
safe.
Pattern 2: Leapfrog Upgrade
For systems without critical state (or systems you’re okay with wiping), you can jump directly from ancient NixOS to the latest unstable:
latestPkgs = import nixpkgs {
system = "x86_64-linux";
rev = "nixos-unstable";
};
_module.args.nixinate = {
host = "10.0.0.1";
sshUser = "deploy";
hermetic = {
enable = true;
nixos-rebuild = latestPkgs.nixos-rebuild-ng;
nix = latestPkgs.nix;
};
};The deployment script copies both the latest nix binary
and the latest nixos-rebuild-ng to the target first, then
uses them for the rebuild. You’re shipping a complete, modern rebuild
engine to a legacy system.
Both patterns are first-class citizens in Nixinate. This is what hermetic mode enables: choice over how you migrate, not a one-size-fits-all forced path.
Phase-Bannered Deploy Scripts: Visible Progress
One of Nixinate’s most valuable features is that every deployment phase emits timestamped markers. When you run a deployment, you see output like this:
Deploying nixosConfigurations.myMachine from /home/user/flake
SSH Target: deploy@10.0.0.1
Rebuild Command: local build : mode test
=== [PRE-COPY START] 2026-07-19T14:23:01Z Pre-copying system closure to myMachine ===
Building and copying system closure to remote store (visible progress):
100% |########################################| 2048/2048 paths
=== [PRE-COPY END] 2026-07-19T14:24:17Z ===
=== [DEPLOY START] 2026-07-19T14:24:17Z Activating myMachine via nixos-rebuild ===
Running nixos-rebuild test on remote (closure already transferred):
...
warning: NixOS configuration loaded from /nix/store/...
=== [DEPLOY END] 2026-07-19T14:24:42Z ===
Every phase has a START and END marker with a timestamp. If a phase hangs, you see START without END. If output is truncated or lost, you know exactly which phase it happened in.
This matters more than it sounds. In my experience, most deployment failures are hangs — network gets stuck, SSH buffer fills up, some utility goes into an infinite wait. With phase banners, you know where to look. You can SSH into the target and see what’s actually running. You can kill the deployment and retry.
Looking at the flake code, you can see how these markers are generated. For local build mode:
activation = if remote then remoteCopy + hermeticActivation else ''
echo "=== [PRE-COPY START] $(date) Pre-copying system closure to ${machine} ==="
echo "Building and copying system closure to remote store (visible progress):"
( ${debug} ${ssh_options} ${nix} ${verboseFlag} ${nixOptions} copy --log-format internal-json "$(${nix} build --print-out-paths --no-link ${system_toplevel})" --to ${safe_ssh_uri} )
echo "=== [PRE-COPY END] $(date) ==="
echo "=== [DEPLOY START] $(date) Activating ${machine} via nixos-rebuild ==="
echo "Running nixos-rebuild $sw on remote (closure already transferred):"
( ${debug} ${ssh_options} ${stdbuf} -oL ${safe_nixos_rebuild} ${nixOptions} "$sw" --flake ${safe_target} --target-host ${safe_target_host} --sudo ${optionalString substituteOnTarget "-s"} )
echo "=== [DEPLOY END] $(date) ==="
'';It’s straightforward bash. The markers are simple echo
statements. The magic is that they’re always there, and they
always work.
Deployment Lock: Atomic mkdir
Concurrent deployments to the same machine are a disaster. Two
simultaneous nixos-rebuild processes can corrupt the
system, leave it in an inconsistent state, or fail silently. Nixinate
prevents this with an atomic deployment lock.
Here’s the locking mechanism from the generated script:
_lockdir="/tmp/nixinate-${machine}.lock"
_lock_timeout=60
_lock_waited=0
while ! mkdir "$_lockdir" 2>/dev/null; do
if [ -f "$_lockdir/pid" ]; then
_lock_pid=$(cat "$_lockdir/pid" 2>/dev/null)
if [ -n "$_lock_pid" ] && ! kill -0 "$_lock_pid" 2>/dev/null; then
echo "Removing stale deployment lock (PID $_lock_pid died)" >&2
rm -rf "$_lockdir" 2>/dev/null
continue
fi
fi
sleep 0.5
_lock_waited=$((_lock_waited + 1))
if [ $_lock_waited -ge $((_lock_timeout * 2)) ]; then
echo "WARNING: Could not acquire deployment lock after $_lock_timeout s, forcing through" >&2
rm -rf "$_lockdir" 2>/dev/null
mkdir "$_lockdir" 2>/dev/null || true
break
fi
done
echo $$ > "$_lockdir/pid"
trap 'rm -rf "$_lockdir" 2>/dev/null' EXIT INT TERM HUPThe lock is a directory. mkdir is atomic on all Unix
systems — it either succeeds or fails, no race conditions. When the
first deployment starts, it creates
/tmp/nixinate-myMachine.lock and writes its PID inside. If
another deployment tries to start, mkdir fails, and it
waits.
The script also detects stale locks — if a deployment crashed and its PID is no longer running, the lock is removed and a new one is created. After 60 seconds of waiting, a warning is printed but the deployment proceeds anyway (maybe the lock mechanism itself broke).
When the deployment finishes (success or failure), a trap removes the lock directory.
This is pure bash, no external locking library, no distributed consensus. It works on every Unix system. It’s simple, reliable, and foolproof.
Progress Tracking: AWK Parsing JSON Logs
The Nixinate deployment script uses an AWK parser to watch Nix’s JSON log format and render real-time progress:
function show_progress() {
if (totals > 0) {
pct = int(done / totals * 100)
bar = ""
for (i = 0; i < 40; i++)
bar = bar ((i < pct * 40 / 100) ? "#" : ".")
printf "\r\033[K %3d%% |%s| %d/%d paths", pct, bar, done, totals > "/dev/stderr"
}
}
# Type 103 start: "copying N paths" declaration — set totals
if (evt_type == 103 && evt_action == "start") {
if (match(json, /"text":"(([^"\\]|\\.)*)"/, _t)) {
if (_t[1] ~ /copying [0-9]+ paths/) {
match(_t[1], /([0-9]+)/, _n)
if (_n[1] > 0) totals = _n[1]
}
}
next
}
# Type 100 start: individual copy events — direction check
if (evt_type == 100 && evt_action == "start") {
if (match(json, /"text":"(([^"\\]|\\.)*)"/, _t)) {
# " to " = deploy phase (count)
if (_t[1] ~ / to /) {
done++
show_progress()
}
}
next
}The parser watches for two key events:
- Type 103 (total declaration): When Nix logs “copying 2048 paths”, the AWK script extracts “2048” and sets the total
- Type 100 (individual copy): For each path copied “to” the remote (not “from”), it increments the done counter and updates the progress bar
The progress bar renders as a 40-character ASCII bar with percentage and counts:
45% |##################................| 923/2048 paths
It works with both nix copy --log-format internal-json
(modern, JSON-based) and legacy nixos-rebuild output
(regex-based fallback). The AWK script handles both gracefully.
Image Generation: Offline Provisioning
Nixinate can generate disk images and bootable installers from your nixosConfiguration. This enables a powerful workflow: build your system once, capture it as an image, and deploy it offline to any machine.
To enable image generation, add this to your flake:
packages = nixinate.lib.genImages.x86_64-linux self;And import the image generation module in your nixosConfiguration:
imports = [
disko.nixosModules.disko
nixinate.nixosModules.image-gen
];Configure which image types you want:
_module.args.nixinate.images = {
raw = {
enable = true; # default: true
imageSize = "20G"; # default: 20G
espSize = "1024M"; # default: 1024M
swapSize = "8G"; # default: 8G
};
installer.enable = true; # default: true
qemu.enable = false; # default: false
iso.enable = false; # default: false
};Then build:
nix build .#myMachine-raw-image # Raw disk image
nix build .#myMachine-raw-image-zstd # Compressed (zstd level 3)
nix build .#myMachine-installer-image # Bootable installerHow Zstd Compression Works
The raw disk image is compressed with zstd at level 3 for faster transfer. Looking at the flake:
rawImageZstd = final.stdenv.mkDerivation {
name = "${machine}-raw-image-zstd";
buildInputs = [ final.zstd ];
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out
echo "Compressing raw image with zstd (level 3, parallel)..."
zstd -3 -T0 -v -o $out/image.raw.zst \
${userConfig.config.system.build.diskoImages}/main.raw
'';
};The -3 flag sets compression level 3 (fast, still good
compression). The -T0 flag uses all CPU cores. A 20GB image
typically compresses to 4-6GB with this setting — significant savings
for transfer.
How Installers Work
The installer is a separate NixOS configuration that bundles the raw image and provides a script to write it to a target disk:
installerDerivedConfig = nixpkgs.lib.nixosSystem {
system = final.system;
modules = [
disko.nixosModules.disko
./modules/images/installer.nix
./modules/images/auto-dd.nix
{ disko.devices.disk.autoinstaller = {
device = "/dev/null";
imageSize = "7800M";
# ...
};
}
];
};The installer is built as a bootable ISO or USB image. When you boot
it, it presents a menu asking which disk to install to, then uses
dd to write the system image to that disk and resizes the
filesystem to fit the target.
This is how you go from “I have a flake” to “I have a bootable USB with my NixOS system” in one command.
The Evolution: From Script to Toolkit
Nixinate didn’t start as a full toolkit. It started as a simple bash script to automate NixOS rebuilds. Over three years, it evolved through real-world use:
Year 1: Basic local-build deployment with SSH. Just building and copying.
Year 2: Hermetic mode for legacy system upgrades. Phase banners for visibility. Deployment locking to prevent conflicts.
Year 3: Remote-build mode for resource-constrained deployers. Progress tracking. Image generation. Tool selection model for incremental and leapfrog upgrades.
Each feature was added because someone needed it in production. The hermetic tool selection model came from needing to migrate database servers one NixOS version at a time. Image generation came from needing to provision bare-metal servers offline. Remote-build mode came from dealing with limited deployer resources.
This is the design pattern I believe in: don’t design the tool in abstract. Build what you need, then generalize from experience.
Comparison to Other Tools
For context, here’s how Nixinate compares to the traditional NixOS deployment tools:
| Tool | Complexity | Network Required | Deterministic | Built in Flake |
|---|---|---|---|---|
| nixos-rebuild | Low | SSH only | No | Yes (default) |
| NixOps | Very High | Yes, state tracking | No | No |
| Colmena | High | Yes, Ruby runtime | No | No |
| Nixinate | Medium | SSH only | Yes (hermetic) | Yes (generated) |
Nixinate is the middle ground. It’s more featureful than raw
nixos-rebuild, but far simpler than NixOps or Colmena. It
generates scripts instead of maintaining state. It’s all bash and
Nix.
What’s Next: The MNGA Vision
Nixinate is evolving toward something bigger. The current version handles NixOS-to-NixOS deployments. The next phases will extend to any Linux system.
Incubation Deployments (Phase 4 in the roadmap) will
let you deploy to bare Ubuntu, Debian, Fedora — any Linux system with
root SSH access and bash. The deployment script will copy a
nix-store into place (no nix-daemon required) and install NixOS on top
of the existing system. This is the “sneaky but absolute” tool:
virtually any Linux becomes a deployment target.
Offline Provisioning (Phase 5) will make image and installer deployment bulletproof. Build once, ship the image anywhere, write to disk, reboot. Zero internet required on the target.
Cross-Architecture Support (Phase 6) will handle deploying from x86_64 to aarch64 and other combinations, using binfmt emulation where needed.
The full roadmap is documented in the nixinate repository’s docs/MNGA-plan.md.
The philosophy is clear: “just bash and nix” — minimal, functional,
declarative, dependable.
TL;DR (Again)
Nixinate generates deployment scripts from your flake. Each machine
gets a bash script you run with nix run .#machineName. It
supports:
- Local and remote builds — choose where compilation happens
- Hermetic mode — deterministic evaluation with pinned tools
- Incremental and leapfrog upgrades — flexible paths for any stateful system
- Phase-bannered scripts — visible progress at every stage
- Atomic locking — prevents concurrent deployments
- Image generation — offline provisioning and bootstrap
- Progress tracking — real-time visibility into copy operations
It’s written in bash and generated from pure Nix. No daemons, no external runtimes, no hidden state. It’s production-grade and actively used. It’s the deployment tool NixOS should have built-in.
Further Reading
- Nixinate README: Full configuration reference, build modes, hermetic patterns
- MNGA Plan: Five-phase roadmap to incubation, imaging, and cross-arch
- Part 2 (Coming Soon): “What Will Nixinate Be?” — the future vision
The tool is open source and ready to use. Add it to your flake today. Give it a try on a non-critical system first. If it works for you (and I think it will), you’ll wonder how you ever deployed NixOS any other way.
Created by Matthew Croughan, who constantly strives for a better path. Evolved through three years of production use into a canonical NixOS deployment tool. This is what works.