The Laptop That Built, and the Two That Did Not
23:40. Kitchen table. pytest is green on the Debian laptop.
The Fedora workstation beside it cannot find clang. The Arch CI image that ran an hour earlier is on a different Python patch level than either machine. I have three host package formats in play if you count the distros, and none of them agree on a compiler. This is the failure mode distribution reviews keep rediscovering: a working tree on one laptop, a missing toolchain on another, and a CI image that drifted because someone upgraded Python on the host.
The only artifacts that actually freeze a shared toolchain are flake.nix, flake.lock, and a direnv .envrc. Another venv still binds language packages to whatever glibc the host shipped. An ad-hoc toolchain image still copies a Dockerfile from laptop to laptop and hopes the base tag did not move.
A Fedora admin, a Debian developer, and an Arch builder enter one input hash without copying Dockerfiles around. Desktop environments do not enter the contract. The lockfile does not care which session you opened.
What flake.nix Must Declare for a Dev Shell
A packaging-focused flake is a small file. I type three keys: description, inputs, and outputs. nixpkgs arrives as a URL pin.
{ description = "shared toolchain"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; outputs = { self, nixpkgs }: let pkgsFor = system: nixpkgs.legacyPackages.${system}; shellFor = system: (pkgsFor system).mkShell { nativeBuildInputs = [ (pkgsFor system).clang (pkgsFor system).python3 ]; buildInputs = [ (pkgsFor system).openssl ]; shellHook = "export PS1='[flake] $PS1'"; }; in { devShells.x86_64-linux.default = shellFor "x86_64-linux"; devShells.aarch64-linux.default = shellFor "aarch64-linux"; }; }outputs.devShells.<system>.default is the entry direnv will load. I keep that name so nix develop and use flake hit the same derivation.
I initially reached for flake-utils to generate outputs for every possible system and save boilerplate. The library became an extra input. That extra input complicated the lockfile for a repository that only ever builds on x86_64-linux and aarch64-linux. I discarded the library and listed those two architectures by hand. The flake inputs stay minimal and the eval stays cheap.
Two System Pins
List x86_64-linux and aarch64-linux yourself. A helper library that enumerates Darwin and every BSD variant bloats flake.lock for a Linux-only tree.
Shell Versus What You Would Build
mkShell and a package output solve different jobs. Compilers, linters, pytest, and clang belong in the shell. Artifacts I would nix build belong in packages. Mixing them means a build pulls a prompt hook, or a shell forgets the binary I meant to ship.
nativeBuildInputs holds the tools that run at edit and test time: clang, the interpreter, the linter. buildInputs holds libraries the code links against. shellHook is the place for a prompt prefix and a handful of env vars. A flake is not a NixOS module. Do not dump services, users, or networking into shellHook and expect a workstation to become a fleet.
How flake.lock Freezes Every Input Hash
flake.lock stores a locked URL, a revision, and a narHash for each input. That triple is the contract laptops and CI share. Change the lock and every machine moves together. Leave it alone and Monday morning matches Friday night.
nix flake lock creates or refreshes missing pins. It fills holes. nix flake update moves pins: it walks inputs and writes new revisions. Updating a single input by name with nix flake update nixpkgs is the middle path when I want a newer clang without touching every other flake input. The Nix flake command reference walks the same verbs with the official flag set.
Treat gitignoring flake.lock as the same class of mistake as omitting a lockfile for a language package manager. The.nix file names intent. The lock names bytes. Without the lock, two clones resolve two different nixpkgs revisions and you are back at the kitchen table with a missing clang.
Commit The Lock
The hash in flake.lock is the artifact Fedora, Debian, and Arch actually share. Commit it on the same change that edits flake.nix.
Hooking direnv so cd Loads the Flake Shell
cd is the activation step once the hook exists. The implementation path is short.
- Install direnv and hook it in the shell rc so every prompt can ask direnv for an environment.
- Write use flake into .envrc at the repo root.
- Run direnv allow once per clone.
I keep nix-direnv as a local eval cache. Without it, every cd can become a full nix print-dev-env. With it, the cached derivation loads and the prompt returns. direnv reload is the command I run after editing flake.nix so the shell picks up the new inputs without leaving the directory.
Host tools leak. A system Python, a Node binary from the distro, a clang sitting on PATH from apt or dnf remain visible unless the flake lists the tools the project needs. The shell composes PATH from the derivation. Anything you omit, the host can still supply, and then Fedora finds a different clang than Debian.
Host PATH Leak
If flake.nix omits python3, clang, or the linter, the host copy wins. Name every tool the tests actually invoke.
Installing Nix Without Fighting Fedora, Debian, or Arch
Two install shapes show up in practice. The multi-user daemon install puts a shared /nix store on the machine and runs a build user. The single-user prefix drops the store under a home directory. CI images often prefer the prefix because the image has one user and no need to share builds across accounts. Laptops that several people log into want the daemon.
Distro Friction I Actually Hit
Fedora SELinux labels on /nix will block the store until the install's documented contexts land. Debian's existing apt toolchain tempts PATH leaks: gcc and python3 are already there, so a thin flake looks like it works until CI, which has a different apt snapshot, fails. Arch's rolling host libraries move under you. A binary you thought came from the flake can still dlopen a host.so if you left a library out of buildInputs.
This is packaging-and-distro comparison work from the verummeum bench, not Nix core maintenance, so daemon troubleshooting stops at documented install flags and store permissions. Privacy & security here means those store permissions and SELinux labels. I stop before rewriting the daemon.
Where Flakes Replace venv, pipx, and Toolchain Images
Python venv and pipx, nvm- and rustup-style version managers, and fat toolchain containers all try to freeze a compiler set. I map them onto mkShell packages so language tools are flake inputs. The install script goes away. The version manager goes away. web tools that used to arrive through nvm become a nodejs attribute on nativeBuildInputs.
Keep containers for production runtime isolation and kernel-level sandboxing. Drop them as the daily driver for compilers, linters, and test runners. A container that exists to give me clang is a slow, large way to write nativeBuildInputs = [ clang ].
One flake.lock versus a compose file plus a requirements.txt that still tracks the host glibc. The compose file pins an image tag. The requirements.txt pins wheels. Neither pins the libc the interpreter was built against. The flake.lock pins URL, revision, and narHash for nixpkgs, which is where that libc lives.
| Component | Legacy Approach | Nix Flake Approach |
|---|---|---|
| Version Pinning | requirements.txt + host glibc | flake.lock (URL, revision, narHash) |
| Environment Activation | Manual source venv/bin/activate | Automatic direnv via use flake |
| Host libc | requirements.txt tracking the host glibc | nixpkgs locked in flake.lock |
Monday, Same Hash, Three Distros
09:12 Monday. The same engineer opens the Debian laptop, lid already warm from the commute. The Fedora workstation still has last night's mug on the coaster. The Arch builder is a terminal on the third monitor. Each directory is a cd. direnv loads from the identical flake.lock revision.
The check is boring on purpose. nix flake metadata prints the locked inputs, or git show on flake.lock shows the same narHash on all three machines. The shell prompt changes. pytest and clang are the versions the lock named.
He edits a linter pin in flake.nix on the Debian laptop, commits the lock, pulls on Fedora and Arch, and cds. Three prompts. One hash.