Image Generation with nixos-generators

Updated

September 12, 2026

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:

  1. Reproducible. The same flake lock produces the same image byte-for-byte.
  2. Cacheable. Images are regular Nix derivations; binary caches work normally.
  3. Composable. Any NixOS module — services, users, firewall rules — works without modification.
  4. 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-iso
warning: 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=fsync
1134264320 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-vm
building '/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-sd
building '/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=fsync
3936280576 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 10

Sample 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-lib

Save 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 switch
activating 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-generators instead 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 both nixosConfigurations and nixosGenerate calls. Images reflect exactly what runs in production.
  • Always pin nixos-generators in flake.lock. Run nix flake update nixos-generators deliberately, not on every build. Image reproducibility depends on a stable lock.
  • Use pkgsCross.aarch64-multiplatform for cross-architecture builds. Do not rely on binfmt emulation in CI; it is slower and silently fails on kernels without the binfmt_misc module loaded.
  • Audit and minimize appliance closures. Measure with nix-store --query --requisites, blame with why-depends, strip stealth subsystems (speechd, graphics), and enforce constraints with system.forbiddenDependenciesRegexes.
  • Name image outputs descriptively (rescue-iso, desk-vm, oob-sd) so nix build .#<tab> is self-documenting.

Try this

  1. Extend the rescue ISO. Add nmap and wireguard-tools to the rescue-iso packages list and rebuild. Verify both binaries appear inside the ISO by mounting it: sudo mount -o loop result /mnt followed by ls /mnt/nix/store | grep -E "nmap|wireguard".

  2. 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.

  3. Add a DigitalOcean image format without duplicating config. In the Case 4 flake, add a packages.x86_64-linux.desk-do output using format = "do" and the same deskBase module. Build it with nix build .#desk-do and inspect the resulting .img.gz.

  4. Measure image closure size. After building any image, run nix path-info -rS $(readlink -f result) | sort -k2 -rn | head -20 to identify the largest packages in the closure. Remove one unnecessary package from the module and compare closure sizes before and after.

  5. Profile and minimize an appliance image. Import (modulesPath + "/profiles/perlless.nix") and set system.forbiddenDependenciesRegexes = [ "perl" ]. Verify with nix build that the output builds cleanly without Perl in the runtime closure.