Nix Flakes Explained Without the Jargon
Inputs, outputs, a lock file, and why the community argued for four years

Contents
Flakes have the worst documentation-to-importance ratio of anything in modern Linux. They are how essentially every current Nix project is structured, every recent blog post assumes them, and the official manual still labels them experimental behind a feature flag. Newcomers land in a topic where the tutorials contradict each other and the reference material politely declines to exist.
The frustrating part is that the concept is small. Strip away the discourse and a flake is two things: a standard file layout for a Nix project, and a lock file. That is genuinely it. Everything else is consequence.
The problem flakes solve
Before flakes, Nix found its packages through channels. A channel is a URL pointing at a nixpkgs tarball, subscribed to per-user or per-machine, updated by running nix-channel --update. Your configuration.nix said pkgs.htop and Nix resolved that against whatever your channel happened to point at that day.
Read that again, because it undermines the entire pitch. Nix promises reproducibility. A build that depends on ambient state on your machine, mutated by a command you ran last Tuesday, is not reproducible in any sense a normal person would accept. Two machines on the same “channel” could be weeks apart. Your config file — the artefact you commit and share — contained no record whatsoever of which nixpkgs produced the result. The most reproducible package manager ever built resolved its dependencies through a global mutable variable.
People worked around it. You could pin nixpkgs with fetchTarball and a hash, or niv, or an overlay with a rev in it, and every project did it slightly differently. It worked and it was all bespoke.
Flakes make the pin mandatory and standard. Your project declares its inputs by URL; Nix resolves them to exact commits and writes them to flake.lock; you commit that file. Now nixos-rebuild on your laptop and on a machine you have not touched in a year produce identical results, because the versions are in the repository rather than in the environment.
Anatomy of a flake
A flake.nix has three top-level attributes: a description, an inputs set, and an outputs function.
| |
Inputs are dependencies. Each is a URL in a scheme Nix understands — github:owner/repo/ref, git+https://..., path:./local, tarball+https://.... That is the whole idea: your project says what it depends on, by name and location.
Outputs are what your project provides, as a function of the resolved inputs. The attribute names are a convention that the nix CLI knows about. nixosConfigurations.<host> is what nixos-rebuild --flake .#<host> looks for. devShells.<system>.default is what bare nix develop runs. packages.<system>.default is what nix build builds. There is no magic; the names are an agreed interface, and if you use a name outside the schema, the CLI simply will not find it.
follows is the line people skip and then regret. Without sops-nix.inputs.nixpkgs.follows = "nixpkgs", sops-nix drags in its own pinned copy of nixpkgs. Ten inputs, ten nixpkgs, and your flake.lock becomes a 900-line document describing eleven slightly different versions of the world, all of which have to be evaluated and some of which get built. follows says: use mine. Add it to every input that has a nixpkgs, from the first line you write.
self is the flake referring to itself, which you need for things like self.rev to stamp a git commit into a build.
What a devShell actually replaces
The devShells output is the flake feature that convinces people who have no interest in NixOS, and it is worth understanding on its own terms because it is the cheapest possible entry point.
Run nix develop in a directory with a flake and you drop into a shell where the listed packages exist, at exactly the versions the lock file pins, with nothing installed on your machine. Leave the shell and they are gone from your PATH. The store paths stay cached, so the second entry is instant.
What that replaces, depending on your habits: a README section headed “Prerequisites” that lies; a Dockerfile you built purely to get a consistent Python; a .tool-versions for asdf that covers three of the seven things you need; the pyenv/nvm/rbenv/jenv stack, each of which solves this for exactly one language. A devShell covers every language at once, because Nix does not care what a package is — python3, postgresql, ffmpeg and a pinned terraform sit in the same list.
The property that matters is that the lock file is committed. A colleague clones the repository, runs nix develop, and has your toolchain — the same compiler, the same jq, the same everything, resolved from the same revisions. No drift, no “works on my machine”, no onboarding document. Pair it with direnv and a two-line .envrc containing use flake and the shell activates when you cd into the directory, which removes the last piece of friction.
The honest limits: the first nix develop on a cold cache downloads a lot, entering a shell adds a beat of evaluation, and anything that expects a language’s native ecosystem to install things at runtime — pip install into a virtualenv, npm install with native modules — needs thought, because those tools want to write into places the store does not allow. There are good answers for each. They are another evening.
The lock file
flake.lock is JSON, generated, and the whole point. It records the exact revision and content hash of every input, transitively.
| |
The pattern that makes this useful in a homelab is boring and effective. Run nix flake update deliberately — monthly, or when you want something new — then nixos-rebuild build to check it evaluates, then commit the lock file with a message saying what moved. Your infrastructure now has a changelog with dates, and when something breaks you can git checkout last month’s flake.lock and rebuild the exact system you had. That is a stronger property than any snapshot, because it survives the disk dying.
The failure mode is running nix flake update reflexively before every build “to be tidy”. That imports several thousand upstream commits you did not read, and when the rebuild fails you have no idea which of them did it. The lock file works because you leave it alone.
Purity, and the one rule that surprises everyone
The rule that catches every newcomer is that flake evaluation is pure. A flake cannot read your environment variables, cannot look at arbitrary paths on your disk, and cannot fetch anything that is not pinned. Everything it consumes has to come through a declared input or from the flake’s own directory.
This is the source of the git-tracking behaviour I listed under troubleshooting, and it is worth understanding as a design choice instead of a quirk. The evaluator copies your flake into the store before evaluating it, and the definition of “your flake” is “the files git knows about”. An untracked file is simply outside the sealed environment. The same logic explains why the home directory is invisible, why a stray environment variable cannot change the result, and why two people on two continents get the same build.
Purity is the property that makes the lock file meaningful. A pinned dependency graph is worthless if the evaluation can still reach outside it and pick up something local, and every reproducibility system that permits an escape hatch eventually finds that everyone is using the escape hatch. Nix closed the door, and the cost is that legitimate workflows involving local state — pointing at a checkout of nixpkgs you are hacking on, reading a machine-specific token — now need an explicit mechanism rather than an ambient one.
The mechanisms exist. A path input can reference a local directory. The --impure flag exists for the cases where you genuinely need it and know why. --override-input swaps a pinned input for a local checkout for the duration of one command, which is the correct tool when you are debugging an upstream module and want your changes visible without committing them anywhere.
The advice is simple: reach for --override-input when developing, and treat every use of --impure as a note to yourself that you have taken on a debt. It will pay itself back on the machine where the ambient thing you depended on turns out to be absent.
Turning them on
Flakes are gated behind two experimental features. On NixOS, put this in your configuration and rebuild once:
| |
On another distro with Nix installed, add to ~/.config/nix/nix.conf:
| |
The “experimental” label has been there for over five years while flakes became the default way everyone works. It reflects a genuine and long-running disagreement inside the project about the design — the CLI, the input schema, the fact that nix flake bundles a fetcher, an evaluator interface and a lock format into one feature — rather than any instability in the implementation. In practice they have been stable for years. The label makes people nervous, which is fair, and it should not stop you.
Troubleshooting
“error: path ‘/nix/store/…’ does not exist” or your new file is ignored. Flakes only see files git tracks. A default.nix you created and did not git add is invisible to the evaluator, and the error will not mention git. git add the file — staging is enough, no commit needed.
“error: Git tree is dirty” as a warning on every command. Informational. It means uncommitted changes exist, so self.rev is unavailable and Nix uses self.dirtyRev. Harmless while iterating; a reason to commit before deploying anything you want to identify later.
Evaluation takes forever and downloads half of GitHub. Missing follows. Check nix flake metadata for the input tree; if you see nixpkgs four times, that is your answer.
“attribute ‘x86_64-linux’ missing” in a devShell. Outputs are per-system and you hardcoded one system, or you are on a different architecture than you assumed. flake-utils or a manual forAllSystems helper is the usual fix, and for a personal homelab flake, hardcoding your architecture is entirely acceptable.
nix build cannot find your package. The output attribute name has to match the schema — packages.x86_64-linux.default, not defaultPackage (deprecated) and not a name you invented.
nixos-rebuild ignores the flake and uses /etc/nixos. You forgot --flake .#hostname. Set nix.registry.nixpkgs.flake = inputs.nixpkgs; and use a --flake habit; the two paths coexist and will confuse you at 23:00.
A homelab repository that stays sane
Once flakes click, the temptation is one giant flake.nix. It works for two machines and becomes unreadable at five. The layout that has survived two years for me is boring and worth stealing:
| |
flake.nix stays short: inputs at the top, then one nixosConfigurations.<host> per machine, each importing ./modules/common.nix, its own hosts/<host>/configuration.nix, and whichever optional modules it needs. The per-host file holds only what makes that host different — its hostname, its disks, its services. Everything shared lives in modules/, written once.
The test of whether the layout is working: adding a machine should be a new directory with two files and three lines in flake.nix. If it is more than that, something host-specific has leaked into a shared module, and the usual culprit is a hardcoded IP address or a disk device path.
hardware-configuration.nix is the one file you never write by hand — it comes from nixos-generate-config on the machine itself and describes its actual disks and kernel modules. Commit it anyway. It is the difference between rebuilding a dead machine in fifteen minutes and rebuilding it after an hour of remembering which drive was the boot pool.
The argument
Flakes attracted a real fight, and it is worth knowing why, because it explains the documentation.
The design case against: flakes conflate several concerns, the URL schemes are a fetcher hardcoded into the language runtime, pure evaluation breaks legitimate workflows, and the whole thing arrived via a company rather than through the community’s RFC process. Those are serious objections from people who understand Nix far better than I do, and the fallout — a maintainer exodus, a fork in Lix, years of an “experimental” flag that everybody ignores — is real.
The practical case for: every alternative to a lock file was worse, and the ecosystem voted with its feet within about eighteen months.
The argument continues and does not affect your homelab. Use flakes. Keep flake.lock in git. If the design is superseded, it will be by something with a lock file that can read yours.
The verdict
Flakes are a lock file with a schema attached, and the jargon around them costs newcomers weeks for no reason. If you run NixOS, use them — the pinning turns the NixOS trade-off from a good idea into an actually reproducible one, and every current module you will want to import assumes them.
If you run Nix on Debian or macOS purely for reproducible development environments, flakes are still the right call for the same reason: nix develop with a committed lock file gives a colleague your exact toolchain, which is the promise that made you look at Nix in the first place.
The one habit worth forming early: follows on every input, and treat nix flake update as a deliberate act with a commit message. Do those two things and flakes will stay boring, which is exactly what you want from a dependency system. And put your secrets in sops-nix rather than the store, before the store makes the decision for you.




