Flakes: A Practical First Look
Nix has been my package manager of choice for about nine months now. For all of that time I ignored flakes, but the constant refrain that they're the best way to use Nix eventually wore me down. What follows are the basic examples I couldn't find anywhere else, plus the problems that nearly sent me back to nix-env -iA.
What a Flake Actually Is
Every explanation of flakes I found framed them in terms of other Nix concepts, which wasn't helpful. What finally clicked was an analogy to Docker container images. Like a Docker image, a flake can:
- contain any software you want to install or compile
- act as a self-contained dev environment with all dependencies set up
- be shared via a
flake.nixfile that others can build identically
The comparison breaks down quickly, though. There are major differences:
- A
flake.nixplusflake.lockguarantees identical builds; aDockerfiledoes not - Flakes run natively on macOS with no VM or Linux layer
- Dependencies are shared efficiently between flakes, and flakes can pick and choose which parts of their dependencies to use
flake.nixis a program in the Nix language, not a list of shell commands- Isolation is done with dynamic linker and rpath tricks, not filesystem overlays, cgroups, or namespaces
What the two share is the core design goal: sharing a dev environment with a single configuration file.
Why Nix Works for Me
People praise Nix for being declarative, reproducible, and functional. My main motivation is simpler: Nix has a lot of pre-compiled binaries for macOS. More than Homebrew does, in my experience, which means fewer source builds. It also means I can build a five-year-old version of Hugo on a Mac without a fight.
Building a Single-Flake Setup
My existing setup was a Homebrew replacement: run nix-env -iA nixpkgs.whatever to install something, done. It worked fine, aside from occasionally breaking randomly. But I thought it would be tidy to have one flake.nix file listing every package I wanted, producing a single directory I could put in my PATH.
The practical advantages are modest: theoretically easier setup on a new machine, and uninstalling becomes deleting a line instead of remembering the right Nix command.
A First Attempt
Starting with Ruby and cowsay, I put together this flake.nix:
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-23.05-darwin";
outputs = { self, nixpkgs }: {
devShell.aarch64-darwin = nixpkgs.legacyPackages.aarch64-darwin.mkShell {
buildInputs = with nixpkgs.legacyPackages.aarch64-darwin; [
cowsay
ruby
];
};
};
}
Here's what I understood about it at the time:
nixpkgsis the central package repositoryaarch64-darwinis my architecture, critical for getting the right binary downloads- An "input" is a dependency; I get to choose which parts of it to use
- The URL scheme is
github:USER/REPO_NAME/TAG_OR_BRANCH_NAME mkShellenablesnix develop, which I abandoned shortly after- The output has to be named
devShell.aarch64-darwinornix developrefuses to run selfandlegacyPackagesremained mysteries
Then I tried to build it:
$ nix build
error: getting status of '/nix/store/w1v41cyqyx4d7q4g7c8nb50bp9dvjm29-source/flake.nix': No such file or directory
That error is inscrutable. Why does Nix think a path in /nix/store/ should exist?
Problem 1: Git Gets in the Way
The issue: Nix flakes have strange rules about Git.
- If the current directory is not a Git repository, everything is fine
- If you are in a Git repo and your files are
git added, everything is fine - If you're in a Git repo and your
flake.nixfile is untracked — just created, for instance — Nix completely ignores it
This behavior is documented but easy to miss:
Note that any file that is not tracked by Git is invisible during Nix evaluation
The fix is simple: git add the file.
Enabling Flake Commands
To use nix build and related commands, you need to enable two experimental features: flakes and nix-command. I put experimental-features = nix-command flakes in ~/.config/nix/nix.conf. The alternative is prefixing every command with nix --extra-experimental-features "flakes nix-command".
With features enabled, nix develop gave me a working shell:
$ nix develop
grapefruit:nix bork$ cowsay hi
____
< hi >
----
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
Inside that shell, the PATH was structured one way I didn't expect:
grapefruit:nix bork$ echo $PATH
/nix/store/v5q1bxrqs6hkbsbrpwc81ccyyfpbl8wk-clang-wrapper-11.1.0/bin:/nix/store/x9jmvvxcys4zscff39cnpw0kyfvs80vp-clang-11.1.0/bin:/nix/store/3f1ii2y5fs1w7p0id9mkis0ffvhh1n8w-coreutils-9.1/bin:/nix/store/8ldvi6b3ahnph19vm1s0pyjqrq0qhkvi-cctools-binutils-darwin-wrapper-973.0.1/bin:/nix/store/5kbbxk18fp645r4agnn11bab8afm0ry3-cctools-binutils-darwin-973.0.1/bin:/nix/store/5si884h02nqx3dfcdm5irpf7caihl6f8-cowsay-3.7.0/bin:/nix/store/5bs5q2dw5bl7c4krcviga6yhdrqbvdq6-ruby-3.1.4/bin:/nix/store/3f1ii2y5fs1w7p0id9mkis0ffvhh1n8w-coreutils-9.1/bin
Every dependency gets its own PATH entry: .../cowsay-3.7.0/bin, .../ruby-3.1.4/bin, and so on. Functional, but not what I wanted. I wanted a single directory of symlinks to drop into my normal shell's PATH.
Getting a Symlink Directory with buildEnv
A suggestion from the Nix Discord pointed me to buildEnv, which assembles a directory of symlinks from Nix packages:
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-23.05-darwin";
outputs = { self, nixpkgs }: {
defaultPackage.aarch64-darwin = nixpkgs.legacyPackages.aarch64-darwin.buildEnv {
name = "julia-stuff";
paths = with nixpkgs.legacyPackages.aarch64-darwin; [
cowsay
ruby
];
pathsToLink = [ "/share/man" "/share/doc" "/bin" "/lib" ];
extraOutputsToInstall = [ "man" "doc" ];
};
};
}
The result is a result/bin full of links:
$ ls result/bin/
bundle bundler cowsay cowthink erb gem irb racc rake rbs rdbg rdoc ri ruby typeprof
That gave me what I could put in my PATH. Next I went about adding every package from my old nix-env -q list.
Problems Adding Packages
Unfree Packages
ngrok is not free, and Nix offered three ways to handle it. Options A and B were most promising:
c) For `nix-env`, `nix-build`, `nix-shell` or any other Nix command you can add
{ allowUnfree = true; }
to ~/.config/nixpkgs/config.nix.
Option C's suggestion to add { allowUnfree = true} to ~/.config/nixpkgs/config.nix did nothing for me. Option A worked:
$ export NIXPKGS_ALLOW_UNFREE=1
Note: For `nix shell`, `nix build`, `nix develop` or any other Nix 2.4+
(Flake) command, `--impure` must be passed in order to read this
environment variable.
Relative Path Flakes
For custom flakes I'd made earlier, I wanted to reference them like this:
hugoFlake.url = "path:../hugo-0.40";
paperjamFlake.url = "path:../paperjam";
The first nix build worked. The second produced an inscrutable error. My workaround was to delete flake.lock before every build. There's a lengthy GitHub issue thread about this behavior, but I never found a proper solution.
The "Build Hook" Error
For a while, every nix build ended with:
$ nix build
error:
… while reading the response from the build hook
error: unexpected EOF reading a line
After much fruitless poking at flake.nix, the fix turned out to be killing the nix-daemon process. I suspect a botched Nix upgrade, and I don't think this is a common issue.
A Broken Package
Adding zulu for Java produced a complaint about a broken symlink:
$ nix build
error: builder for '/nix/store/4n9c4707iyiwwgi9b8qqx7mshzrvi27r-julia-dev.drv' failed with exit code 2;
last 1 log lines:
> error: not a directory: `/nix/store/2vc4kf5i28xcqhn501822aapn0srwsai-zulu-11.62.17/share/man'
For full logs, run 'nix log /nix/store/4n9c4707iyiwwgi9b8qqx7mshzrvi27r-julia-dev.drv'.
$ ls /nix/store/2vc4kf5i28xcqhn501822aapn0srwsai-zulu-11.62.17/share/ -l
lrwxr-xr-x 29 root 31 Dec 1969 man -> zulu-11.jdk/Contents/Home/man
The zulu package in nixpkgs-23.05 appears to have been broken; it's since been fixed in the unstable branch. I already had Java installed elsewhere, so I removed zulu from the list and moved on.
Wiring It Into My PATH
With the problems solved, I wrote a small script to build my flake and symlink the result to ~/.nix-flake. The rm flake.lock handles the relative-path problem, and NIXPKGS_ALLOW_UNFREE covers ngrok:
#!/bin/bash
set -euo pipefail
export NIXPKGS_ALLOW_UNFREE=1
cd ~/work/nixpkgs/flakes/grapefruit || exit
rm flake.lock
nix build --impure --out-link ~/.nix-flake
Then I put ~/.nix-flake at the front of my PATH.
GC Roots
All the experimentation had eaten about 20GB in /nix/store, so I wanted to run garbage collection. I found two commands — nix-store --gc and nix-collect-garbage — and couldn't tell you the difference. The latter seemed to delete more.
Before collecting, I needed to confirm ~/.nix-flake was a GC root so my packages wouldn't be swept away. Running nix-store --gc --print-roots showed it was there. That command also runs a GC, so it was a slightly dangerous way to check, but it worked out.
Speed Concerns
Installing a small package with nix-env -iA took two seconds:
$ time nix-env -iA nixpkgs.sl
installing 'sl-5.05'
these 2 paths will be fetched (0.41 MiB download, 3.77 MiB unpacked):
/nix/store/yv1c98m5pncx3i5q7nr7i7mfjkiyii72-ncurses-6.4
/nix/store/2k78vf30czicjs0dq9x0sj4017ziwxkn-sl-5.05
copying path '/nix/store/yv1c98m5pncx3i5q7nr7i7mfjkiyii72-ncurses-6.4' from 'https://cache.nixos.org'...
copying path '/nix/store/2k78vf30czicjs0dq9x0sj4017ziwxkn-sl-5.05' from 'https://cache.nixos.org'...
building '/nix/store/zadpfs9k1cw5x7iniwwcqd8lb7nnc7bb-user-environment.drv'...
________________________________________________________
Executed in 1.96 secs fish external
The same install via flakes took seven seconds, plus editing time:
$ vim ~/work/nixpkgs/flakes/grapefruit/flake.nix
$ time nix-symlink
________________________________________________________
Executed in 7.04 secs fish external
usr time 1.78 secs 0.29 millis 1.78 secs
sys time 0.51 secs 2.03 millis 0.51 secs
I don't have a fix for that, so I'm living with it for now.
Final Workflow
My routine is now:
- Edit
flake.nixto add or remove packages - Rerun the
nix-symlinkscript - Periodically run
nix-collect-garbage
One last task was setting up the registry:
nix registry add nixpkgs github:NixOS/nixpkgs/nixpkgs-23.05-darwin
That pins nix run nixpkgs#cowsay to the 23.05 nixpkgs version. I wanted to avoid re-downloading the repository, and I'm perfectly happy with pinned versions rather than chasing nixpkgs-unstable.
Where This Leaves Me
My solutions are probably not the best available. But I now have a working setup built on one relatively simple flake.nix and a six-line bash script, with no extra abstraction layers I don't understand. I might try flakey-profile someday, as it looks similarly minimal.
Flakes can manage far more — language package ecosystems, dotfiles, whole system configuration via tools like home-manager. That's not for me right now. I need my setup simple enough to debug when it breaks.
I still find Nix deeply confusing, but once something works, it tends to stay working. Whether that holds true for flakes is still an open question. If it goes badly, I can always go back to nix-env -iA.



