Work in Progress — POC only
The context: old PCs running Windows 10 that can't upgrade to Windows 11. End-of-life is coming, migration isn't an option. The idea is to replace Windows with NixOS — but these machines have spinning HDDs where boot time is a real concern.
This POC is a first step to understand whether Composefs could help reduce the symlink overhead in the Nix store. I'd like to keep testing the actual impact before drawing any conclusions.
When NixOS builds a user environment, it creates a system-path derivation — a directory full of symlinks pointing back into the Nix store, one per file per package:
/run/current-system/sw/bin/git → /nix/store/xxx-git/bin/git
/run/current-system/sw/bin/curl → /nix/store/yyy-curl/bin/curl
...
On the test machine:
$ find /nix/store/ -type l | wc -l
271037
$ find /nix/store/ | wc -l
951281
~30% of all store entries are symlinks. This ratio is structural to NixOS: symlinks are its primary mechanism for aggregating isolated packages into coherent environments.
The top contributors:
$ find /nix/store -type l -printf '%h\n' \
| awk -F/ '{print $4}' | sed 's/^[a-z0-9]\{32\}-//' \
| sort | uniq -c | sort -rn | head -10
34619 lutris-0.5.18-fhsenv-rootfs
33159 steam-1.0.0.85-fhsenv-rootfs
33087 mint-y-icons-1.8.8
32324 mint-l-icons-1.7.8
22628 appimage-run-fhsenv-rootfs
13396 mint-x-icons-1.7.4
5911 onlyoffice-desktopeditors-9.1.0-fhsenv-rootfs
5652 openssl-3.6.2-man
4240 ncurses-6.5
2304 system-path (per generation)The biggest culprits are icon themes (~33k symlinks each, one per icon alias per size) and FHS environment roots. FHS envs (Steam, Lutris, OnlyOffice, ...) recreate a full Linux directory tree in symlinks to make proprietary binaries believe they're running on a standard Linux system — these symlinks are resolved at every cold launch, before the kernel page cache is warm.
On HDD with limited RAM (the typical profile of old Windows 10 machines), this matters: the first launch of an app like OnlyOffice after boot has to resolve ~5900 symlinks with a cold cache, each potentially causing a random seek. system-path adds another ~2300 symlinks resolved at every service startup during boot.
Whether symlinks are actually the dominant bottleneck in these scenarios is something I haven't measured yet.
Composefs stores the directory tree in a compact metadata file (.cfs), with file contents in a separate content-addressed object store. Symlinks in a .cfs are just entries in that metadata file — no separate inodes on disk.
Instead of a buildEnv generating hundreds of symlinks at build time, each package could be mounted as a composefs image and merged with overlayfs.
Composefs supports xattr labels per file in its metadata, which is relevant for SELinux environments where every file needs a security context. Worth exploring further.
Add to your NixOS configuration:
environment.systemPackages = [ pkgs.composefs ];Then rebuild: sudo nixos-rebuild switch.
Requires overlayfs and erofs kernel modules (available by default on NixOS). No extra kernel module needed — mount.composefs is a userspace helper.
| File | Description |
|---|---|
test.nix |
Toy derivations (libfoo, appbar) for basic testing |
nar-to-composefs.py |
Parses a NAR stream, extracts files into a content-addressed object store, produces a .cfs image |
poc-basic.sh |
Builds, converts, mounts and runs libfoo+appbar via composefs |
poc-no-symlinks.sh |
Compares buildEnv (symlinks) vs composefs overlay |
bash poc-basic.sh # basic composition POC
bash poc-no-symlinks.sh # symlink comparison POCBuilds hello, curl and git, then creates two unified environments and counts symlinks:
buildEnv (classic) : 260 symlinks
composefs overlay : 150 symlinks
The 110 missing symlinks are the aggregation symlinks — the ones buildEnv creates to point from a merged directory back into each package:
/nix/store/xxx-demo-env/bin/git → /nix/store/yyy-git/bin/git ← eliminated
/nix/store/xxx-demo-env/bin/curl → /nix/store/zzz-curl/bin/curl ← eliminated
With composefs, the overlay merges packages directly — no intermediate symlinks needed. The 150 remaining symlinks are intra-package ones (sbin → bin, lib64 → lib, etc.) that are part of the packages themselves and are stored as compact metadata entries inside the .cfs image, without requiring separate filesystem inodes.
All three binaries run correctly from the composefs overlay.
- Measure actual boot time difference on a real HDD
- Test with a full system profile instead of 3 packages
- Understand integration cost with the rest of NixOS