Image Generation with nixos-generators
Image Generation with nixos-generators
Every team eventually needs a bootable artifact: a rescue ISO burned to a USB stick, a QEMU image for local integration testing, an SD card for a rack of Raspberry Pis, or an AMI pushed to AWS. The boring default for all of these is nixos-generators — a flake library that accepts a standard NixOS module configuration and a format string, then produces a ready-to-use image from the Nix store. No hand-crafted genisoimage invocations, no bespoke mkimage.sh scripts, no per-format wiki pages.
Mental model
nixos-generators (hosted at github:nix-community/nixos-generators) exposes one primary function: nixosGenerate. It is a thin wrapper around NixOS’s own nixos-rebuild evaluation machinery.
nixosGenerate {
system = "x86_64-linux"; # or "aarch64-linux"
format = "iso"; # selects the image format module
modules = [ ./my-config.nix ];
}
Under the hood, nixosGenerate imports <nixpkgs/nixos> with the caller-supplied modules plus a format-specific module bundled inside nixos-generators. That format module enables or disables NixOS options (bootloader type, filesystem layout, network seed, cloud-init, etc.) appropriate for the target image. The result is a standard Nix derivation whose output path contains the finished image file.
Supported formats (selection):
| Format string | Output file | Use case |
|---|---|---|
iso |
.iso |
Live USB / CD (no installer scripts) |
install-iso |
.iso |
NixOS installer (nixos-install / Calamares) |
qcow |
.qcow2 |
QEMU / libvirt local VMs |
amazon |
.vhd |
AWS AMI import |
sd-aarch64 |
.img |
Raspberry Pi / ARM SBCs |
vagrant-virtualbox |
.box |
Vagrant + VirtualBox |
do |
.img.gz |
DigitalOcean custom images |
azure |
.vhd |
Azure managed disk import |
Key properties:
- Reproducible. The same flake lock produces the same image byte-for-byte.
- Cacheable. Images are regular Nix derivations; binary caches work normally.
- Composable. Any NixOS module — services, users, firewall rules — works without modification.
- No extra tools on the build host. All format-specific utilities (
genisoimage,qemu-img,e2fsprogs, etc.) are pulled into the build sandbox automatically.
format = "iso" is a live image. Onboarding USB sticks want install-iso (or modulesPath + "/installer/cd-dvd/installation-cd-minimal.nix"). Distro name, splash, and Calamares branding are a later chapter — generators only pick the image kind.
Worked examples
Case 1: Bootable rescue ISO for the infrastructure desk
The team keeps a rescue ISO on a shelf USB drive. When a desk workstation fails to boot, the technician plugs in the USB and gets an SSH-accessible minimal shell with the usual diagnostic tools already present.
Save as flake.nix:
# flake.nix
{
description = "Desk rescue ISO";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
nixos-generators = {
url = "github:nix-community/nixos-generators";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, nixos-generators, ... }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
packages.${system}.rescue-iso = nixos-generators.lib.nixosGenerate {
inherit system;
format = "iso";
modules = [
({ pkgs, ... }: {
# ISO label shown in boot menu
isoImage.isoName = "desk-rescue-26.05.iso";
# Make the ISO available without authentication for emergency access.
# Restrict by placing this on a physically separate VLAN in production.
services.openssh = {
enable = true;
settings.PermitRootLogin = "yes";
};
users.users.root.password = "rescue";
# Diagnostic tools every technician expects
environment.systemPackages = with pkgs; [
vim
curl
htop
tcpdump
smartmontools
parted
pciutils
usbutils
];
# Stateless — no mutable filesystem needed
system.stateVersion = "26.05";
})
];
};
};
}Build:
nix build .#rescue-isowarning: Git tree '/home/ops/desk-infra' is dirty
building '/nix/store/3j7k...-nixos-rescue-iso.drv'...
/nix/store/vq84...-iso/iso/desk-rescue-26.05.iso
Inspect the result symlink:
readlink -f result/nix/store/vq84p3rz8f2a1n06xbkj5wsywi3hqd29-iso/iso/desk-rescue-26.05.iso
Flash to USB:
sudo dd if=$(readlink -f result) of=/dev/sdX bs=4M status=progress conv=fsync1134264320 bytes (1.1 GB, 1.1 GiB) copied, 47.3 s, 24.0 MB/s
Case 2: QEMU qcow2 image for local VM testing
Before deploying a new service configuration to production hosts, engineers spin up a local QEMU VM to validate it. The qcow format produces a copy-on-write disk image that QEMU understands natively. No conversion step is needed.
Save as flake.nix:
# flake.nix
{
description = "Desk service VM image";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
nixos-generators = {
url = "github:nix-community/nixos-generators";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, nixos-generators, ... }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
packages.${system}.desk-vm = nixos-generators.lib.nixosGenerate {
inherit system;
format = "qcow";
modules = [
({ pkgs, ... }: {
# A minimal internal metrics collector for VM validation
services.prometheus = {
enable = true;
exporters.node = {
enable = true;
openFirewall = true;
};
};
networking.firewall.allowedTCPPorts = [ 9090 9100 ];
# QEMU guest agent for clean shutdown via virsh/libvirt
services.qemuGuest.enable = true;
users.users.ops = {
isNormalUser = true;
extraGroups = [ "wheel" ];
password = "ops";
};
services.openssh = {
enable = true;
settings.PasswordAuthentication = true;
};
system.stateVersion = "26.05";
})
];
};
};
}Build:
nix build .#desk-vmbuilding '/nix/store/a9xm...-desk-vm-qcow2.drv'...
/nix/store/f1yw...-nixos.qcow2
Launch the VM with QEMU directly:
qemu-system-x86_64 \
-m 2048 \
-smp 2 \
-enable-kvm \
-drive file=$(readlink -f result),format=qcow2,if=virtio \
-net nic,model=virtio \
-net user,hostfwd=tcp::2222-:22,hostfwd=tcp::9090-:9090 \
-nographic[ 0.000000] Linux version 6.6.56 (nixbld@localhost) ...
[ 2.148391] EXT4-fs (vda): mounted filesystem with ordered data mode.
[ OK ] Started OpenSSH Daemon.
[ OK ] Started Prometheus Node Exporter.
<<< NixOS 26.05 (desk-vm) >>>
desk-vm login:
SSH in from a second terminal:
ssh -p 2222 ops@127.0.0.1[ops@desk-vm:~]$
Case 3: Raspberry Pi SD card image (aarch64 cross-compile from x86_64)
The team operates a small fleet of Raspberry Pi 4 boards as out-of-band management nodes on the server rack. Images must be built on a standard x86_64 workstation and flashed to SD cards.
Save as flake.nix:
# flake.nix
{
description = "Rack OOB node SD image";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
nixos-generators = {
url = "github:nix-community/nixos-generators";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, nixos-generators, ... }:
let
buildSystem = "x86_64-linux";
# Cross-compilation: build on x86_64, target aarch64
pkgsCross = nixpkgs.legacyPackages.${buildSystem}.pkgsCross.aarch64-multiplatform;
in
{
packages.${buildSystem}.oob-sd = nixos-generators.lib.nixosGenerate {
# The *host* (target board) architecture
system = "aarch64-linux";
# Supply pkgs built via the cross toolchain so no aarch64 runners are needed
pkgs = pkgsCross;
format = "sd-aarch64";
modules = [
({ pkgs, lib, ... }: {
# Raspberry Pi 4B hardware support
hardware.raspberry-pi."4".apply-overlays-dtmerge.enable = true;
hardware.deviceTree.enable = true;
networking.hostName = "oob-node";
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "no";
PasswordAuthentication = false;
};
};
users.users.ops = {
isNormalUser = true;
extraGroups = [ "wheel" ];
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAA... ops@desk"
];
};
security.sudo.wheelNeedsPassword = false;
environment.systemPackages = with pkgs; [
vim
curl
iproute2
iputils
];
system.stateVersion = "26.05";
})
];
};
};
}Build (cross-compilation; no QEMU emulation required):
nix build .#oob-sdbuilding '/nix/store/c2hm...-sd-image-aarch64-linux.drv'...
/nix/store/p9bw...-nixos-sd-image-26.05.aarch64-linux.img
Find the image path and flash it:
SD_IMG=$(readlink -f result)
sudo dd if="$SD_IMG" of=/dev/mmcblk0 bs=4M status=progress conv=fsync3936280576 bytes (3.9 GB, 3.7 GiB) copied, 124 s, 31.7 MB/s
Eject the card, insert it into the Pi, and power on. The node appears on the rack management VLAN within 30 seconds.
Case 4: Sharing a NixOS module between a live machine and an image build
The biggest maintenance risk when maintaining images is drift: the image diverges from the live system because they evolved separately. The fix is to extract shared configuration into a standalone module file and import it in both the nixosConfigurations output and the nixosGenerate call.
Save as modules/desk-base.nix:
# modules/desk-base.nix
{ pkgs, lib, ... }:
{
# Shared by both live desk machines and generated images
networking.domain = "ops.example.internal";
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "no";
PasswordAuthentication = false;
X11Forwarding = false;
};
};
environment.systemPackages = with pkgs; [
vim
git
curl
htop
jq
];
security.sudo.wheelNeedsPassword = true;
time.timeZone = "UTC";
nix = {
settings.experimental-features = [ "nix-command" "flakes" ];
gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 30d";
};
};
system.stateVersion = "26.05";
}Save as flake.nix:
# flake.nix
{
description = "Desk infrastructure — shared module demo";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
nixos-generators = {
url = "github:nix-community/nixos-generators";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, nixos-generators, ... }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
# The shared module — imported in both outputs below
deskBase = ./modules/desk-base.nix;
in
{
# ── Live system deployed to real hardware ────────────────────────────
nixosConfigurations.desk-01 = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
deskBase
./hosts/desk-01/hardware-configuration.nix
({ ... }: {
networking.hostName = "desk-01";
users.users.alice = {
isNormalUser = true;
extraGroups = [ "wheel" ];
};
})
];
};
# ── ISO image for emergency rescue of desk machines ──────────────────
packages.${system}.desk-rescue-iso = nixos-generators.lib.nixosGenerate {
inherit system;
format = "iso";
modules = [
# Same shared module → same tools, same SSH policy, same Nix config
deskBase
({ lib, ... }: {
networking.hostName = "desk-rescue";
# Rescue-only override: allow root login for emergency access
services.openssh.settings.PermitRootLogin = lib.mkForce "yes";
users.users.root.password = "rescue";
isoImage.isoName = "desk-rescue-26.05.iso";
})
];
};
# ── QEMU image for pre-deploy validation ─────────────────────────────
packages.${system}.desk-vm = nixos-generators.lib.nixosGenerate {
inherit system;
format = "qcow";
modules = [
deskBase
({ lib, ... }: {
networking.hostName = "desk-vm";
services.qemuGuest.enable = true;
users.users.root.password = "vm";
services.openssh.settings.PermitRootLogin = lib.mkForce "yes";
})
];
};
};
}Build both images without touching modules/desk-base.nix:
nix build .#desk-rescue-iso/nix/store/8k3n...-iso/iso/desk-rescue-26.05.iso
nix build .#desk-vm/nix/store/r7cj...-nixos.qcow2
Any change to modules/desk-base.nix — adding a package, tightening an SSH setting — is reflected in both the live system and every image output on the next build. There is no separate image-maintenance checklist.
Case 5: Minimizing image closures for lightweight appliances
A standard NixOS image often ships with documentation, desktop hooks, and interpreters that a headless appliance or edge node does not need. The image can inflate from 300 MB to over 1.5 GB without any obvious additions in your configuration.
Measure the heaviest packages in the image closure:
nix-store --query --requisites $(nix build --print-out-paths .#packages.x86_64-linux.desk-appliance) \
| xargs du -sm | sort -n | tail -n 10Sample output:
43 /nix/store/...-systemd-257.9
63 /nix/store/...-perl-5.40.0
114 /nix/store/...-python3-3.13.7
144 /nix/store/...-linux-6.12.53-modules
229 /nix/store/...-mesa-25.2.5
541 /nix/store/...-llvm-21.1.1-lib
648 /nix/store/...-source
Trace the unexpected dependencies with nix why-depends:
nix why-depends $(nix build --print-out-paths .#packages.x86_64-linux.desk-appliance) \
/nix/store/...-llvm-21.1.1-libSave optimization settings in modules/appliance-minimal.nix:
# modules/appliance-minimal.nix
{ lib, pkgs, modulesPath, ... }:
{
# 1. Eliminate Perl completely from the image
imports = [ (modulesPath + "/profiles/perlless.nix") ];
# Assert that Perl never leaks back into the closure
system.forbiddenDependenciesRegexes = [ "perl" ];
# 2. Disable stealth subsystems
# speechd pulls mbrola which includes 650MB+ of voice sources
services.speechd.enable = false;
# Mesa + LLVM are unnecessary on headless appliances
hardware.graphics.enable = false;
# Audio and input stacks that drag in Python via gstreamer
services.pipewire.enable = false;
services.libinput.enable = false;
# 3. Strip font catalogs
fonts.enableDefaultPackages = false;
fonts.fontconfig.enable = false;
fonts.packages = lib.mkForce [ pkgs.dejavu_fonts ];
# 4. Stub hardcoded upstream tools via overlays
nixpkgs.overlays = [
(final: prev: {
# Stub xdg-utils to bash when a desktop module hardcodes it
xdg-utils = final.bash;
})
];
}Import modules/appliance-minimal.nix into your image definition. The closure drops from ~1.5 GB to ~360 MB, cutting download times and attack surface.
The trap
Building sd-aarch64 on x86_64 without cross-compilation
The sd-aarch64 format targets aarch64-linux. If you forget to supply a cross-compiled pkgs and simply pass the host pkgs, Nix will attempt to run aarch64 ELF binaries inside the build sandbox on an x86_64 kernel:
# flake.nix (wrong — do not use)
packages.x86_64-linux.bad-sd = nixos-generators.lib.nixosGenerate {
system = "aarch64-linux";
# No pkgs override — falls back to x86_64 pkgs, then tries to exec aarch64
format = "sd-aarch64";
modules = [ ./modules/desk-base.nix ];
};Build error:
building '/nix/store/...-bash-5.2-aarch64-unknown-linux-gnu.drv'...
error: builder for '/nix/store/...-bash-5.2-aarch64-unknown-linux-gnu.drv' failed:
error: executing '/nix/store/...-bash-5.2/bin/bash':
Exec format error
The kernel refuses to execute an aarch64 ELF. There are two correct fixes.
Fix A — Cross-compilation (no emulation needed, recommended):
# flake.nix (relevant excerpt)
let
buildSystem = "x86_64-linux";
pkgsCross = nixpkgs.legacyPackages.${buildSystem}.pkgsCross.aarch64-multiplatform;
in
{
packages.${buildSystem}.correct-sd = nixos-generators.lib.nixosGenerate {
system = "aarch64-linux";
pkgs = pkgsCross; # cross-compiled pkgs supplied explicitly
format = "sd-aarch64";
modules = [ ./modules/desk-base.nix ];
};
}Fix B — binfmt emulation (slower, requires a NixOS build host):
Add this to your build host’s configuration.nix, then nixos-rebuild switch:
# configuration.nix (build host only)
{ ... }:
{
boot.binfmt.emulatedSystems = [ "aarch64-linux" ];
}nixos-rebuild switchactivating the configuration...
setting up binfmt handlers...
registering aarch64-linux as binfmt_misc handler
With binfmt active, aarch64 ELFs are transparently executed under QEMU user-mode emulation. Builds succeed but take significantly longer than cross-compilation. Use Fix A for routine CI pipelines; Fix B only when cross-compilation is not available.
The boring rule
- Use
nixos-generatorsinstead of custom image scripts. All format-specific tooling is inside the Nix sandbox; your repository stays clean. - Share modules between live systems and image builds. Put common configuration in
modules/files and import them in bothnixosConfigurationsandnixosGeneratecalls. Images reflect exactly what runs in production. - Always pin
nixos-generatorsinflake.lock. Runnix flake update nixos-generatorsdeliberately, not on every build. Image reproducibility depends on a stable lock. - Use
pkgsCross.aarch64-multiplatformfor cross-architecture builds. Do not rely on binfmt emulation in CI; it is slower and silently fails on kernels without thebinfmt_miscmodule loaded. - Audit and minimize appliance closures. Measure with
nix-store --query --requisites, blame withwhy-depends, strip stealth subsystems (speechd,graphics), and enforce constraints withsystem.forbiddenDependenciesRegexes. - Name image outputs descriptively (
rescue-iso,desk-vm,oob-sd) sonix build .#<tab>is self-documenting.
Try this
Extend the rescue ISO. Add
nmapandwireguard-toolsto therescue-isopackages list and rebuild. Verify both binaries appear inside the ISO by mounting it:sudo mount -o loop result /mntfollowed byls /mnt/nix/store | grep -E "nmap|wireguard".Boot the QEMU image with a persistent overlay. Create a 4 GB writable overlay with
qemu-img create -f qcow2 -b $(readlink -f result) -F qcow2 overlay.qcow2 4G, then boot it with the overlay path. Confirm that writes survive a reboot while the base image stays unchanged.Add a DigitalOcean image format without duplicating config. In the Case 4 flake, add a
packages.x86_64-linux.desk-dooutput usingformat = "do"and the samedeskBasemodule. Build it withnix build .#desk-doand inspect the resulting.img.gz.Measure image closure size. After building any image, run
nix path-info -rS $(readlink -f result) | sort -k2 -rn | head -20to identify the largest packages in the closure. Remove one unnecessary package from the module and compare closure sizes before and after.Profile and minimize an appliance image. Import
(modulesPath + "/profiles/perlless.nix")and setsystem.forbiddenDependenciesRegexes = [ "perl" ]. Verify withnix buildthat the output builds cleanly without Perl in the runtime closure.