Multi-boot partitioning / Cache isolation with btrfs
Why ~/.cache deserves its own subvolume
~/.cache is write-heavy transient data: npm packages, Rust build artifacts, Cargo registry, fnm Node.js installations, browser caches. CoW is the wrong semantic for all of it.
Every cache write under a CoW subvolume causes btrfs to:
- Write the new data block at a new location
- Update the extent tree to point to the new location
- Keep the old block referenced until it is no longer needed
For files that are rewritten frequently and are never worth snapshotting or checksumming, this is pure metadata overhead. It also inflates snapshot sizes — a snapshot of @void that includes a heavily written cache directory captures every CoW version of those cache files.
The @home-cache subvolume solves this cleanly: nodatacow, no checksums, no snapshot inclusion, mounted at ~/.cache.
Creating the subvolume
# mount the pool root
mount -o subvolid=5 /dev/nvme0n1p5 /mnt/pool-root
# create the subvolume
btrfs subvolume create /mnt/pool-root/@home-cache
# set nodatacow on the subvolume directory
chattr +C /mnt/pool-root/@home-cache
umount /mnt/pool-root
chattr +C sets the nodatacow attribute on the subvolume root directory. New files created inside inherit the flag. Files copied in do not retroactively lose CoW — they need to be re-created or have chattr +C set on them individually.
Verify:
lsattr -d ~/.cache
# ---------------C------- /home/younes/.cache
fstab entry:
UUID=d7a98795-1604-4baa-ae07-05e29a3ff1e6 /home/younes/.cache btrfs subvol=@home-cache,nodatacow,noatime 0 0
The nodatacow mount option and the chattr +C attribute are complementary. The mount option sets the default for the mount; chattr +C on the directory ensures the attribute is persistent in the filesystem.
The managed-* sentinel pattern
~/.cache is not mounted as a btrfs subvolume on every machine. The same dotfiles run on:
- This Void Linux machine (btrfs,
@home-cachemounted) - macOS
- Other Linux machines without this btrfs layout
Tools like npm, fnm, and rustup read their cache paths from environment variables. Setting those variables unconditionally in dotfiles would redirect caches to ~/.cache/managed-npm everywhere — including machines where that path is on a regular CoW filesystem or where the intent was not to redirect.
The solution: a sentinel file. If ~/.cache/managed-npm exists, set the npm cache to it. If it does not exist, set nothing and let the tool use its default.
# ~/.zshenv
# npm cache
[[ -d "$HOME/.cache/managed-npm" ]] && export npm_config_cache="$HOME/.cache/managed-npm"
# fnm
[[ -d "$HOME/.cache/managed-fnm" ]] && export FNM_DIR="$HOME/.cache/managed-fnm"
# rustup
[[ -d "$HOME/.cache/managed-rustup" ]] && export RUSTUP_HOME="$HOME/.cache/managed-rustup"
On the Void machine, create the sentinel directories once:
mkdir -p ~/.cache/managed-npm ~/.cache/managed-fnm ~/.cache/managed-rustup
The directories must exist for the condition to be true. They double as the actual cache directories — no separate sentinel file is needed. On any other machine without these directories, the environment variables are never set, and the tools fall back to their defaults.
Why zshenv and not zshrc
zshenv is sourced for every zsh instance: interactive shells, non-interactive shells, scripts, and subshells. zshrc is sourced only for interactive shells.
Build tools, language runtimes, and package managers often spawn non-interactive subprocesses. npm install may fork child processes. Cargo spawns linkers and compilers. If npm_config_cache is set only in zshrc, those subprocesses do not inherit the cache path when launched non-interactively.
zshenv ensures the cache paths are set in all contexts. Keep zshenv minimal — it is executed on every shell spawn, including short-lived scripts. Heavy computations or slow commands in zshenv add latency to every shell invocation.
Tools migrated
| Tool | Environment variable | Sentinel directory |
|---|---|---|
| npm | npm_config_cache |
~/.cache/managed-npm |
| fnm | FNM_DIR |
~/.cache/managed-fnm |
| rustup | RUSTUP_HOME |
~/.cache/managed-rustup |
FNM_DIR controls where fnm stores Node.js installations, not just its cache. Migrating it to @home-cache means Node.js binaries live on the nodatacow subvolume. This is appropriate — Node.js installations under fnm are re-downloadable and not worth checksumming.
RUSTUP_HOME controls the full rustup installation: toolchains, components, and the registry. Toolchains are large (several GB per target), write-heavy during installation, and completely re-downloadable. nodatacow is the right home for them.
What stays outside @home-cache
Not everything in ~/.cache benefits from nodatacow. Browser profile data — session history, cookies, form autofill — is sometimes written but is also read frequently and benefits from CoW’s data integrity guarantees. The split is pragmatic: redirect tools with explicit environment variables; let everything else fall through to the default ~/.cache behavior.
If the browser’s cache directory specifically (as distinct from its profile directory) becomes a concern, chattr +C can be set on ~/.cache/chromium/Cache or ~/.cache/mozilla/firefox/*/cache2 individually without moving the entire browser state.
Connection closed. Returning to terminal...