NixOS ISO Bloat: 416MiB of Nothing You Asked For
Build the most stripped-down NixOS ISO you can — no vim, no curl, no editors, no utilities — and you still get a 458MiB file. Add Alpine Linux to the same thought experiment and you get 66MiB. The 7x gap isn't a bug, a packaging failure, or something that will be fixed in the next release. It's a structural consequence of what NixOS actually is, and until you understand exactly where those bytes live, any attempt to reduce them will be guesswork.
A recent deep-dive published on June 19, 2026 by a NixOS engineer documents exactly this investigation, starting from a nixpkgs pin at 567a49d1913ce81ac6e9582e3553dd90a955875f (NixOS 26.05) and the standard iso-image.nix module. The conclusion isn't a compression trick or a packaging patch. It's a diagnosis — and the prognosis depends entirely on which part of NixOS you're willing to give up.
The Context: Why ISO Size Matters Now
NixOS's promise to infrastructure teams is compelling: fully reproducible, declarative system configurations that produce content-addressed artifacts. For teams building CI runner images, VM appliances for libvirt, edge deployment targets, or bare-metal provisioning toolchains, an immutable NixOS ISO is an attractive primitive. Every system that boots from it is byte-for-byte identical. There's no configuration drift, no package version skew, no "it works on my runner" debugging.
The practical problem is that this promise comes with a floor tax. Docker images for minimal services routinely sit under 20MiB. Alpine-based VM images hover around 66MiB. When teams first evaluate NixOS for appliance delivery or CI image pipelines, the 458MiB baseline for a near-empty configuration is usually the first number that stops the conversation.
The thin VM path — nixos-rebuild build-vm — sidesteps this entirely by mounting /nix/store from the host into the guest. For local development and testing, that works. But the moment you need to ship a self-contained image to a libvirt cluster, a remote bare-metal host, or a customer environment where you can't guarantee a Nix installation on the receiving end, you're back to embedding the full closure in a squashfs. And that closure is where the conversation gets interesting.
Dissecting 458MiB
The investigation uses du to break the ISO apart after mounting, and the numbers are specific:
| Component | Size |
|---|---|
nix-store.squashfs | 416 MiB |
| initrd (linux-6.18.35) | 26 MiB |
| kernel bzImage | 13 MiB |
| Everything else | < 5 MiB |
The kernel and initrd together account for 39MiB. That's roughly what you'd expect from any modern Linux distribution — the 6.x kernel tree is not small, and the initrd has to carry enough to bootstrap the squashfs mount. These are largely fixed costs that don't compress further without switching to a stripped kernel config, and even aggressive kernel configuration typically saves only a few megabytes.
The 416MiB nix-store.squashfs is the entire story. It's also where the counterintuitive part lives: no userland tools are installed. This is a configuration with a near-empty environment.systemPackages. The 416MiB isn't vim, bash utilities, Python runtimes, or developer tooling. It's the transitive closure of whatever you need to boot.
This is Nix's content-addressed store model working exactly as designed. When you declare a NixOS system, the build system computes the full dependency graph of every package in your configuration — not just the packages you listed, but their dependencies, and their dependencies' dependencies, all the way down to glibc. Everything in that graph gets materialized into /nix/store with a cryptographic hash prefix. The squashfs is a snapshot of that entire directory tree.
For a minimal NixOS system, the dominant entries in that graph aren't things you chose. They're the transitive dependencies of systemd and glibc.
The Real Culprit: systemd's Dependency Graph
This is the finding that most engineers don't internalize until they try to ship NixOS: a "bare" NixOS system isn't a kernel plus a minimal init. It's a kernel plus the entire dependency closure of systemd, and systemd's closure is substantial.
systemd pulls in D-Bus, util-linux, libmount, kmod, and a non-trivial chunk of the GNU toolchain. Each of those has its own transitive dependencies. The NixOS module system is built around systemd's unit file model — services.*, systemd.services.*, networking.* all ultimately generate .service files that systemd manages. This tight integration is what makes NixOS's declarative configuration so expressive, but it also means you can't remove systemd without abandoning most of the module system.
To actually audit what's in that 416MiB, the right tool is nix-store -qR /run/current-system for a flat list of closure members, or nix-tree --derivation for an interactive dependency tree. The latter is the important one, because closure size reduction is nonlinear: removing a package from environment.systemPackages only shrinks the squashfs if that package's dependencies have no other reverse dependencies in the closure. In practice, most of what you'd want to remove is already a shared dependency of something else. nix-tree exposes which nodes in the graph have unique reverse-dependency paths — those are the only ones where removal actually moves the size needle.
The squashfs format itself offers another lever that's frequently reached for prematurely: compression algorithm. The default is xz, which maximizes compression ratio. Switching to zstd cuts roughly 15-20% from the compressed size while delivering 3-5x faster decompression. For edge VMs on slow storage, that decompression speedup matters significantly at boot time. The trap is that teams benchmark on fast NVMe SSDs, observe that zstd decompression is faster, and then deploy to spinning disks where the 15-20% size increase becomes the dominant cost. Measure your actual storage before treating compression tuning as a win.
The content-addressed but non-layered nature of nix-store.squashfs creates a second production problem independent of size. Docker users are accustomed to layer caching: a 10-line change to application code rebuilds only the top layer, not the base OS. The NixOS squashfs has no layering concept. A one-line change to your configuration.nix regenerates the entire 416MiB squashfs because any change to the closure produces a different content hash for the root store path. Teams that adopt NixOS ISOs without accounting for this will find their CI pipeline uploading 416MiB on every configuration commit. S3 caching helps but doesn't eliminate the cost; A/B partition schemes with shared store mounts are the more principled solution for high-cadence environments.
The 7x Gap Is a Feature-Parity Problem, Not a Packaging Problem
The Alpine comparison is the most commonly cited data point in discussions about NixOS image size, and it's the most consistently misread one. Alpine's 66MiB ISO boots to an ash shell with busybox and musl libc. The NixOS 458MiB ISO boots to a fully functional systemd-managed system with journald running, udev managing device events, and a live Nix daemon ready to install packages declaratively. Comparing them on image size and concluding that NixOS has a packaging problem is like comparing a motorcycle to a car on fuel efficiency and declaring motorcycles superior.
The question isn't which is smaller. The question is whether you need a car.
This reframing matters because it changes the engineering conversation. If your goal is a bootable environment for a single-purpose appliance with a fixed service set, the path to size reduction runs through the init system, not the package manager. Swapping systemd for a minimal init — dinit, s6, or a hand-rolled runit configuration — is the highest-leverage single change available. Estimates from teams who have done this put the closure reduction at 50-80MiB for the init system alone, before any other changes. The compatibility cost is steep: you lose the entire NixOS module system's unit file generation. Every service that was declaratively managed through services.* needs to be reimplemented in your init system's native format. For a purpose-built appliance with three known services, that's manageable. For a general-purpose NixOS system, it's a complete rewrite of your operational model.
Switching from glibc to musl is the other large-scale lever, and it compounds with the init swap. musl is smaller, but a significant portion of the software ecosystem either doesn't support it or requires patches to build correctly. The further you push this direction, the less you're running NixOS and the more you're running a custom Nix-built Linux — which is a legitimate engineering project, but a different one with a different maintenance profile.
What Developers Should Actually Do
The right tool depends on what you're actually deploying, and the honest answer is that NixOS ISOs are frequently the wrong tool for the job their teams are trying to do.
For container workloads, nixpkgs.dockerTools.buildLayeredImage is the correct primitive. It produces images that are often under 50MiB for a single-service application by computing only the exact closure of your application binary, excluding any init system. You get Nix's reproducibility guarantees, content-addressed layers, and none of the systemd overhead. If your workload runs in a container orchestrator, there is no reason to be in the ISO path.
For VM appliances where you need the full NixOS module system, the thin-VM approach (nixos-rebuild build-vm) beats a self-contained ISO in every dimension except portability. The guest mounts /nix/store from the host at runtime — image size drops to a few megabytes, updates are instant, and you retain full module system compatibility. Use this path wherever you can guarantee a Nix installation on the hypervisor. The self-contained ISO is correct only when you genuinely cannot make that guarantee.
For teams that must ship a self-contained ISO — bare-metal targets, customer-delivered appliances, air-gapped environments — the investigation's methodology is the right starting point:
- Build with
iso-image.nixand measure the baseline withdu. - Run
nix-tree --derivationon your system closure to identify nodes with unique reverse-dependency paths. - Audit the top 10 store paths by size. Most teams find 3-5 removable packages in this list that account for a disproportionate fraction of the total.
- Consider init system alternatives only if you're building a fixed-service appliance and can accept the module system compatibility cost.
- Treat compression algorithm selection (xz vs. zstd) as a boot-time optimization decision, not a size optimization — measure on your actual deployment hardware before treating it as a win.
For CI pipelines building and uploading ISOs on every commit, budget the 458MiB transfer cost explicitly before committing to the approach. Even with aggressive S3 caching, a 7x size multiplier over Alpine translates directly to runner minutes and egress spend at scale. Teams that discover this after building their pipeline around NixOS ISOs face an expensive retrofit.
The Takeaway
The NixOS engineer's investigation correctly identifies the squashfs as the lever. The harder follow-on work — auditing the closure with nix-tree, evaluating init system tradeoffs, deciding whether the portability requirement actually justifies a self-contained ISO — is where the real size reduction lives.
458MiB for a near-empty configuration is not a bug. It's a precise measurement of what a fully functional, reproducible, systemd-managed NixOS system costs to embed. Whether that cost is justified is a product decision, not a technical one. Teams that answer that question clearly before picking their deployment primitive will save themselves a significant retrofit further down the road.
The Alpine comparison will keep appearing in these discussions. The honest response to it is to ask whether your deployment actually needs journald, udev, a Nix daemon, and a fully functional module system at boot — because that's what the extra 392MiB buys you. If the answer is yes, 458MiB is a reasonable price. If the answer is no, you probably shouldn't be in the NixOS ISO path at all.
Sources & Editorial Disclosure
This article was researched and written with AI assistance (Claude by Anthropic) as part of StackRadar's automated editorial pipeline. Content was synthesised from the following public developer community sources: Lobste.rs · ArXiv CS · Dev.to.
All technical claims, version numbers, benchmarks, and project details should be independently verified against official documentation or the original sources listed above. StackRadar analyses and synthesises publicly available information and does not claim original authorship of the underlying events, projects, or research described. Mention of any project, product, or organisation does not constitute an endorsement by StackRadar. This content is provided for informational purposes only — 2026-06-20.