nixfs

Tell it what storage the host meets. It resolves the toolchain — pinned, identical, on any distro.

A NixOS module that declares the filesystem, block-layer and recovery toolchain as one fact per host: nixfs.media. NixOS already installs check/repair userland for the filesystems a host mounts. Nothing installs userland for the filesystems a host meets — a USB stick handed to it, a disk pulled from a retired machine, an SD card of unknown origin — and on a host whose own distro is not NixOS, nothing installs either kind. nixfs closes both halves by resolving to nixpkgs everywhere, so the tools you reach for when a disk is failing are pinned and identical no matter which box you're standing at.

nixfs — declaring media on a host that meets removable drives
$ nixos-rebuild switch
error: Failed assertions:
  - nixfs.media must be set explicitly when nixfs.enable = true. It cannot be
    inferred: what storage a machine encounters is a fact about how it is
    used, not about its hardware or its config. Pick the lowest tier that is
    still true of this host -- none < fixed < removable < arbitrary.

$ $EDITOR configuration.nix
  imports = [ inputs.nixfs.nixosModules.default ];

  nixfs = {
    enable = true;
    media = "removable";
  };

$ nix eval .#nixosConfigurations.host.config.nixfs.packageNames
[ "dosfstools" "mtools" "exfatprogs" "ntfs3g" "cryptsetup" "lvm2" "mdadm"
  "ddrescue" "testdisk" "smartmontools" "hdparm" "nvme-cli" "usbutils"
  "pciutils" "gptfdisk" "parted" ]

$ nixos-rebuild switch
...done. All sixteen now on this host's PATH -- cumulative from `fixed`
too, since tiers accumulate.

The problem

NixOS already solves half of this. Anything named in fileSystems.* gets its check-and-repair userland installed automatically, derived from what the host actually mounts. That part works, and nixfs deliberately does not duplicate it.

Nothing installs userland for the filesystems a host meets rather than mounts: a USB stick handed to it, a disk pulled from a retired machine, an SD card of unknown origin. And on a host whose own distro is not NixOS, nothing installs either kind — those tools arrive by hand, at whatever version the distro shipped the day somebody remembered, or they never arrive at all. Both halves are the same failure, and it is the worst-shaped one there is: you discover which tools are missing at the exact moment you need them.

How it works

1

Declare what the host meets

nixfs.media answers a single question: what kinds of storage does this machine encounter? One of none, fixed, removable, arbitrary. There is no default — a guess is silently wrong in the direction that matters, since too low a tier installs nothing and looks fine right up until the tools are needed.

2

The catalogue resolves it

Every tier up to and including the chosen one contributes its additions; nixfs.filesystems, volumes, recovery, inspection, partitioning and throughput layer on top for anything the tier doesn't cover. The result lands in the read-only nixfs.packageNames — the whole contract, inspectable before anything builds.

3

One file installs it, on either backend

modules/install.nix resolves every name against this host's own pkgs and writes environment.systemPackages. It is exported unchanged as both nixosModules.default and systemManagerModules.default — there is no platform-specific installer to write.

Find your host's tier

Every other way of slicing this turned out to be a proxy for one question: what kinds of storage does this machine encounter? A server that ingests old drives and a server that only ever sees its own NVMe want completely different toolchains, and calling both "server" hides that — so the tier is named after the exposure itself.

  • none
  • fixed
  • removable
  • arbitrary
Tier What it means What it adds
none No block devices of its own — a container, or a guest handed one virtual disk it never inspects. Nothing. Declare nixfs.filesystems for whatever it does mount.
fixed Owns its disks; nothing foreign is ever plugged in. Drive health (SMART, ATA, NVMe, bus topology), GPT/parted partitioning, LUKS to reopen its own encryption.
removable People plug things into it. The formats consumer devices ship with (FAT, exFAT, NTFS), the block layers a foreign Linux disk hides its filesystem under (LVM, mdraid), and data recovery (ddrescue, testdisk).
arbitrary Media of unknown, possibly ancient format arrives to be ingested. Everything — including the formats nothing creates today, which is precisely why they turn up on old disks: ext, XFS, btrfs, F2FS, HFS+, UDF, JFS, NILFS2, plus SCSI/enclosure inspection and throughput tools.

Each tier is a strict superset of the one below it by construction, not by promise: a tier can only declare what it adds, and the cumulative set is computed. There is no way to write "remove" here, so moving a host up a tier can only ever grow its toolchain. CI checks it anyway, at every consecutive pair.

A host's own mounts are a separate axis. nixfs.filesystems is additive on top of whichever tier you pick, for the case the tier says nothing about: a container that mounts btrfs and meets nothing is media = "none" plus filesystems = [ "btrfs" ], and gets exactly that.

Architecture

Six groups, twenty-five catalogue entries, resolving to twenty-nine nixpkgs packages at the top tier. One path turns a tier into an installed toolchain, and it is the same path on either backend.

One file, because there is nothing left to differ

modules/install.nix is exported unchanged as nixosModules.default and systemManagerModules.default. That is only possible because nixfs resolves to nixpkgs on every host regardless of its own distro — there is no platform package manager to shell out to, so there is no platform-specific installer to write.

A missing package fails the build, not a warning

Every resolved name is checked against this evaluation's pkgs before anything installs. nixpkgs does drop packages — ReiserFS tooling went when the kernel dropped the filesystem. A recovery tool that silently stopped being installed is the worst outcome this module could produce, so it throws, with the name in the message, instead of quietly installing less.

ZFS is deliberately not in the catalogue

ZFS userland has to match the loaded kernel module exactly, so it can only come from whatever provides that module — boot.zfs on NixOS, the distro's own packaging elsewhere. A second, independently-versioned copy from nixfs would be a hazard, not a convenience, and a CI check exists so that adding it later has to be a deliberate, visible act.

What you get

One declared fact, not a guess

The whole configuration surface is nixfs.media: none, fixed, removable or arbitrary. No default exists, and none ever will — too low a tier installs nothing and looks fine right up until the tools are needed.

Mounts and meets, both closed

NixOS already installs userland for what a host mounts, from fileSystems.*; nixfs does not duplicate that. It closes the other half — what a host merely meets — on NixOS and on a non-NixOS host alike.

Tiers are monotone, and it is checked

Each tier declares only what it adds; there is no way to write "remove". A higher tier is a strict superset of every lower one by construction, and CI checks every consecutive pair rather than trusting the comment.

A missing package fails the build

Every resolved name is checked against this host's own nixpkgs before install. A tool that silently stopped being packaged is discovered at eval time, with its name in the error — not while a disk is already dying.

ZFS stays out, on purpose

ZFS userland has to match the loaded kernel module, so it can only come from whatever supplies that module. nixfs asserts against a second, independently-versioned copy ever being added to the catalogue.

omit always warns

The escape hatch exists for one honest case: a package broken or marked insecure in the pinned nixpkgs, where the alternative is that the host cannot build at all. Every use is reported in warnings, and the warning does not go away on its own.

Mounts stay independent of tier

nixfs.filesystems is additive on top of whatever the tier gives, for the host whose tier says nothing about what it mounts: a container is media = "none" plus filesystems = [ "btrfs" ], and gets exactly that.

24 eval-time tests, no VM

Each test evaluates a real configuration through NixOS's own eval-config.nix and system-manager's own makeSystemConfig, and inspects what the module renders. Covers catalogue resolution, tier monotonicity, and that both backends resolve the same input to the same packages.

Get started

1. Add the flake input

inputs.nixfs.url = "github:julian-corbet/nixfs-corbet-ch";

2. Import and declare

imports = [ inputs.nixfs.nixosModules.default ];

nixfs = {
  enable = true;
  media = "removable";
};

Works unchanged under system-manager on a non-NixOS host: same options, systemManagerModules.default instead of nixosModules.default.

3. Add anything beyond the tier

nixfs.filesystems = [ "btrfs" ];
nixfs.omit = [ "filesystems.hfs" ];

filesystems, volumes, recovery, inspection, partitioning and throughput each take extra catalogue keys on top of the tier. omit takes one away, and always warns while it does.

Policy only, no installer

Want nixfs.packageNames without environment.systemPackages being touched, to wire it into something else yourself? Import nixosModules.policy / systemManagerModules.policy instead of .default.

View on GitHub

Key options

nixfs.enableTurn the module on.
nixfs.media"none", "fixed", "removable" or "arbitrary". No default: required whenever enable = true, or evaluation fails.
nixfs.filesystemsFilesystem userland beyond the tier — normally what the host actually mounts. Additive; on NixOS usually unnecessary, since fileSystems.* already covers it.
nixfs.volumes / .recovery / .inspection / .partitioning / .throughputAdditive per-group selections on top of the tier, for the host that needs one more thing than its tier gives it.
nixfs.omitEscape hatch, written as "<group>.<key>". Always reported in warnings; a key not in the catalogue is a build error, not a no-op.
nixfs.selectedRead-only. Per group, the catalogue keys this host resolved to.
nixfs.packageNamesRead-only. The resolved selection as nixpkgs attribute names — what modules/install.nix actually installs.