Skip to content

Instantly share code, notes, and snippets.

@siraben
Created June 24, 2026 21:51
Show Gist options
  • Select an option

  • Save siraben/a8fce9912891d85e1ec3cf74081ba4f6 to your computer and use it in GitHub Desktop.

Select an option

Save siraben/a8fce9912891d85e1ec3cf74081ba4f6 to your computer and use it in GitHub Desktop.
Smallest bootable NixOS ISO (~91 MiB) — continuing https://natkr.com/2026-06-19-nixos-but-smol/ (183→91 MiB), boots to an autologin root shell

smallest-iso

An aggressively minimized, bootable NixOS ISO, continuing where natkr's "NixOS but smol" left off. The post took a stock minimal ISO from 458 MiB → 183 MiB. This takes that 183 MiB result and pushes it much further while keeping it a real NixOS system that boots to an autologin root shell.

Build & verify

# Build the ISO
nix-build smallest-iso/iso.nix --attr isoImage --no-out-link

# Boot it headless in QEMU and screenshot/serial-capture the result
bash smallest-iso/boottest.sh <path>/iso/nixos.iso 60

boottest.sh boots the ISO under QEMU/KVM, captures the serial console (the config adds console=ttyS0) and a framebuffer screenshot, and is how every step below was verified to still reach root@nixos:~]#.

What was done (each verified booting)

Starting from a faithful reproduction of the post's config (183 MiB):

  1. xz everything. isoImage.squashfsCompression = "xz -Xdict-size 100% -Xbcj x86" and an xz initrd. Gotcha: the kernel's built-in xz initramfs decompressor cannot read multi-threaded (-T0) streams — it silently truncates the initramfs and panics at root mount. Use -9 -e --check=crc32.
  2. Cheap closure trims. Drop sudo, nano (drags in file/libmagic), and empty out the 13 MiB udev hwdb.bin.
  3. Slim systemd, globally. A withXxx = false override turns off ~30 subsystems (tpm2, repart, importd, homed, networkd, analyze, …). Applied as an overlay (not just systemd.package) so library consumers (dbus-broker, hostname, util-linux) link the slim build instead of pulling a second, full ~63 MiB systemd. Matching NixOS modules (coredump, oomd, timesyncd) are disabled, and the initrd's hardcoded systemd-bsod (a qrencode feature we stripped) is suppressed.
  4. Drop pure-PATH tools nothing else references: the bind host tool, ping, curl, the hostname binary.
  5. Trim the initrd modules to just what a QEMU/KVM guest needs to find and mount the CD (ata_piix ahci sr_mod sd_mod virtio_*); the iso-image and etc-overlay modules still contribute squashfs/iso9660/overlay/erofs.
  6. Smaller kernel image via XZ self-compression (KERNEL_XZ).
  7. Drop switch-to-configuration (system.switch.enable = false) — an ISO is immutable, and it dragged in the 10 MiB C++ stdlib.
  8. util-linux minimal, globally — the full build (its own 14 MiB lib + a 9.8 MiB bin + a 5.9 MiB sqlite) is referenced directly by agetty/mount, so only an overlay swap removes it.

Results

Step ISO size
Post reproduction (baseline) 183 MiB
xz squashfs + initrd 160 MiB
sudo/nano/hwdb trims 156 MiB
global slim-systemd overlay 137 MiB
drop host/curl/iputils/hostname 135 MiB
kernel XZ + initrd module trim + no switch-to-configuration 128 MiB
global util-linuxMinimal overlay 125 MiB
disable networking, drop iproute2/elfutils 118 MiB
disable lvm2 + console setup (drop kbd from initrd) 114 MiB
coreutils-full → coreutils + C.UTF-8 locale 111 MiB
dbus → slim systemd (drop systemd-minimal) 106 MiB
global util-linuxMinimal overlay 125 → applied earlier
disable lastlog (drop sqlite) + systemd withOpenSSL=false 98 MiB
systemd withLibarchive=false (drops openssl) 95 MiB
systemd withVConsole=false (drop kbd) + su xauth off 93 MiB
dbus x11Support=false (drop libX11 stack) 92 MiB
trim optional PATH tools (gnutar/bzip2/cpio/…) 91 MiB

Every row was verified to boot to root@nixos:~]# in QEMU (serial + VGA). 91 MiB is exactly half the post's 183 MiB, and ~80% below the original stock 458 MiB minimal ISO the post started from.

Where the remaining bytes go (≈232 MiB closure → ≈91 MiB ISO)

The big irreducibles left: glibc (35), the slim systemd (35), the kernel bzImage + initrd (≈32 combined), the C++ stdlib pulled by nixos-init (10), two util-linux builds, bash, and linux-headers-static (leaked by the suid wrappers we must keep for unix_chkpwd). Further wins would need a custom minimal kernel config (high risk/effort) or replacing glibc — out of scope here.

Things that are deliberately kept

  • security.wrappers — pam_unix needs unix_chkpwd; nuking the wrappers makes login fail with "Authentication service cannot retrieve authentication info" and getty respawns forever. The wrappers share one linux-headers-static reference (~7 MiB nar, but compresses small).
  • systemd-in-initrd — required by system.etc.overlay, which is what lets the post (and us) drop perl.
#!/usr/bin/env bash
# Boot an ISO in qemu headless, grab a framebuffer screenshot after N seconds,
# and (if console=ttyS0 is set) capture the serial log. Used to confirm the
# minimal ISO still reaches the autologin root shell.
# boottest.sh <iso> [wait_seconds] [outdir]
set -euo pipefail
ISO=${1:?usage: boottest.sh <iso> [wait] [outdir]}
WAIT=${2:-45}
OUT=${3:-/tmp/claude-1002/-home-siraben-nixpkgs/a0cd7556-f8b2-4465-a6e6-52d9f9d76542/scratchpad/boot}
mkdir -p "$OUT"
MON="$OUT/mon.sock"
SER="$OUT/serial.log"
rm -f "$MON" "$SER" "$OUT/screen.ppm" "$OUT/screen.png"
qemu-system-x86_64 \
-enable-kvm -m 2048 -smp 2 \
-cdrom "$ISO" -boot d \
-nographic -serial "file:$SER" \
-monitor "unix:$MON,server,nowait" \
-vga std &
QPID=$!
trap 'kill $QPID 2>/dev/null || true' EXIT
sleep "$WAIT"
# screendump via the human monitor
printf 'screendump %s\n' "$OUT/screen.ppm" | socat - "unix-connect:$MON" >/dev/null 2>&1 || true
sleep 2
if [ -f "$OUT/screen.ppm" ]; then
pnmtopng "$OUT/screen.ppm" > "$OUT/screen.png" 2>/dev/null || true
fi
echo "=== serial tail ==="
tail -n 25 "$SER" 2>/dev/null || echo "(no serial output)"
echo "=== screenshot: $OUT/screen.png ==="
ls -l "$OUT/screen.png" 2>/dev/null || echo "(no screenshot)"
# Smallest bootable NixOS ISO (~91 MiB). Boots to an autologin root shell.
# Continues https://natkr.com/2026-06-19-nixos-but-smol/ -- see README.md.
#
# nix-build smallest-iso/iso.nix --attr isoImage --no-out-link
#
# Builds against the surrounding nixpkgs checkout. For out-of-tree use, replace
# `import ../. { ... }` with a pinned `import (fetchTarball <nixpkgs-url>) { ... }`.
let
# systemd stripped to the core (pid1/journald/logind/udev/pam/nss/seccomp).
# Applied as an OVERLAY below, not just `systemd.package`, so libsystemd
# consumers (dbus, util-linux, ...) don't pull a second full ~63 MiB systemd.
slimSystemdFlags = {
withAnalyze = false;
withApparmor = false;
withAudit = false;
withBootloader = false;
withCoredump = false;
withCryptsetup = false;
withDocumentation = false;
withEfi = false;
withFido2 = false;
withGcrypt = false;
withHomed = false;
withHostnamed = false;
withHwdb = false;
withImportd = false;
withLibarchive = false; # only importd used it -> also drops openssl
withLibBPF = false;
withMachined = false;
withNetworkd = false;
withNspawn = false;
withOomd = false;
withOpenSSL = false; # safe: the features that assert on it are all off above
withPCRE2 = false;
withPolkit = false;
withPortabled = false;
withQrencode = false;
withRemote = false;
withRepart = false;
# withResolved stays on: nss-resolve is tied to withNss and meson rejects the
# combination otherwise.
withSysupdate = false;
withTimesyncd = false;
withTpm2Tss = false;
withUkify = false;
withVConsole = false; # console.enable is off -> drops kbd
withVmspawn = false;
};
pkgs = import ../. {
overlays = [
(final: prev: {
systemd = prev.systemd.override slimSystemdFlags;
# Minimal util-linux everywhere. The full build (+ its sqlite) is pulled
# by agetty/mount/activation directly, so a $PATH swap can't remove it.
# Replicated inline -- referencing util-linuxMinimal would form a cycle.
util-linux = prev.util-linux.override {
cryptsetupSupport = false;
nlsSupport = false;
ncursesSupport = false;
shadowSupport = false;
translateManpages = false;
pamSupport = false; # PAM lastlog is disabled below -> drops sqlite
withLastlog = false;
# systemdSupport stays: provides the lib/tmpfiles.d NixOS collects.
};
# Reuse our slim systemd for dbus's systemctl (drops the separate ~21 MiB
# systemd-minimal); x11Support off drops the libX11 stack from a headless
# image.
dbus = prev.dbus.override {
systemdMinimal = final.systemd;
x11Support = false;
};
})
];
};
in
pkgs.nixos (
{ lib, config, ... }:
{
system.stateVersion = "26.05";
services.getty.autologinUser = "root";
# Serial console: makes the headless image usable and lets us watch boot.
boot.kernelParams = [
"console=tty0"
"console=ttyS0,115200"
];
imports = [
"${pkgs.path}/nixos/modules/installer/cd-dvd/iso-image.nix"
# Stub so the many modules that read programs.ssh don't pull it in.
{ options.programs.ssh = lib.mkOption { }; }
];
image.baseName = lib.mkForce "nixos";
# --- Compression ---
isoImage.squashfsCompression = "xz -Xdict-size 100% -Xbcj x86";
# xz the initrd too. NOTE: the kernel's built-in XZ initramfs decoder cannot
# read multi-threaded (-T0) streams -- they truncate and panic at root mount.
boot.initrd.compressor = "xz";
boot.initrd.compressorArgs = [ "-9" "-e" "--check=crc32" ];
# --- No Nix / docs / locale archive ---
nix.enable = false;
systemd.services.register-nix-paths = lib.mkForce { };
documentation.enable = false;
documentation.man.enable = false;
# C.UTF-8 is built into glibc -> ship no locale archive, no login warnings.
i18n.defaultLocale = "C.UTF-8";
i18n.supportedLocales = lib.mkForce [ ];
# --- No networking (not needed to reach a shell; sheds dhcpcd + iproute2) ---
networking.firewall.enable = false;
networking.useDHCP = false;
networking.dhcpcd.enable = false;
networking.resolvconf.enable = false;
# --- Closure trims ---
security.sudo.enable = false; # useless for autologin-root; leaks headers
security.sudo-rs.enable = false;
programs.nano.enable = lib.mkForce false; # nano -> libmagic's `file` (~11 MiB)
services.lvm.enable = false; # no LVM volumes here
console.enable = false; # no keymap/font setup -> drops kbd; VGA console is fine
environment.etc."udev/hwdb.bin".source = lib.mkForce ( # ~13 MiB, useless in a VM
builtins.toFile "empty-hwdb.bin" ""
);
# Keep security.wrappers: pam_unix needs unix_chkpwd or login loops with
# "Authentication service cannot retrieve authentication info".
# Match the stripped systemd: these default-on modules point at binaries we
# removed, and login is the only thing enabling lastlog / su the only thing
# forwarding X auth.
systemd.coredump.enable = false;
systemd.oomd.enable = false;
services.timesyncd.enable = false;
security.pam.services.login.lastlog.enable = lib.mkForce false; # -> drops sqlite
security.pam.services.su.forwardXAuth = lib.mkForce false; # -> drops libX11
disabledModules = [ "programs/ssh.nix" ];
environment.defaultPackages = lib.mkForce [ ];
# systemPackages = corePackages, but with coreutils-full swapped for the lean
# coreutils (already in the closure) and $PATH-only tools removed. Anything
# activation uses is referenced by store path and stays regardless -- this
# only sheds packages that exist solely to be in $PATH.
environment.systemPackages = lib.mkForce (
map (p: if lib.getName p == "coreutils-full" then pkgs.coreutils else p) (
builtins.filter (
p:
!builtins.elem (lib.getName p) [
"bind" # the DNS `host` tool -> bind (~4 MiB)
"iputils" # ping
"curl"
"hostname"
"iproute2" # networking off; `ip` would only drag in elfutils
"libressl" # corePackages' netcat is libressl's nc
# Optional archive/diff tools nothing in the boot path needs:
"gnutar"
"bzip2"
"cpio"
"diffutils"
"which"
"time"
"mkpasswd"
"getconf"
]
) config.environment.corePackages
)
);
system.extraDependencies = lib.mkForce [ ];
# --- initrd: only the modules a QEMU/KVM guest needs to find the CD ---
# Drop the default module zoo. iso-image already adds squashfs/iso9660/
# overlay/uas and the etc-overlay adds erofs; append (no mkForce) so they stay.
boot.initrd.includeDefaultModules = false;
boot.initrd.availableKernelModules = [
"ata_piix" # QEMU i440FX IDE cdrom
"ahci" # QEMU q35 SATA cdrom
"sr_mod" # SCSI/ATAPI CD-ROM
"sd_mod" # SCSI disk (virtio-scsi / USB)
"virtio_pci"
"virtio_blk"
"virtio_scsi"
];
# systemd-bsod is a qrencode feature we stripped; drop the initrd's hardcoded
# references to it.
boot.initrd.systemd.suppressedUnits = [ "systemd-bsod.service" ];
boot.initrd.systemd.suppressedStorePaths = [
"${config.boot.initrd.systemd.package}/lib/systemd/systemd-bsod"
];
# --- Kernel ---
# XZ self-compression -> smaller bzImage than the default zstd.
boot.kernelPatches = [
{
name = "xz-compress";
patch = null;
structuredExtraConfig = with lib.kernel; {
KERNEL_ZSTD = lib.mkForce no;
KERNEL_XZ = yes;
};
}
];
# Strip the (now unused) on-disk kernel modules from the system toplevel.
system.systemBuilderCommands = lib.mkAfter "rm $out/kernel-modules";
# --- etc-overlay + userborn replace perl; switch-to-configuration is dead
# weight on an immutable image (drops ~10 MiB C++ stdlib) ---
system.etc.overlay.enable = true;
system.etc.overlay.mutable = false;
services.userborn.enable = true;
system.switch.enable = false;
}
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment