Nix Flake
Every repository an agent works in should have a flake.nix. It gives you —
and any human or agent that comes after you — a one-command, reproducible
toolchain: nix develop drops into a shell with the project's compiler,
package manager, and linters, independent of whatever happens to be installed
on the host.
This skill covers two things: noticing when a repo is missing its flake.nix,
and writing a good one. The template below is the official default since
August 2026 (NixOS/templates#103),
explained in depth by
Nixcademy.
Checking for a flake
When you start working in a Git repository, check whether a flake.nix
already exists at the repo root:
test -f "$(git rev-parse --show-toplevel)/flake.nix"
- If it exists, carry on with the user's task. Nothing to do here.
(Exception: if it uses
flake-utils, you may offer a migration — see Migrating away from flake-utils.) - If it is missing, offer to add one as part of your work: a single sentence like "This repo has no flake.nix — want me to add one with a dev shell for ?" Then continue with what the user actually asked. Do not silently create files in someone else's project, but do not nag either: if they decline, drop it and move on.
- Ask which toolchain belongs in the dev shell only if the project type is genuinely ambiguous. Usually marker files tell you everything (see below).
Skip the offer entirely for checkouts that aren't the user's own work (e.g. vendored dependencies) or repos where Nix clearly isn't wanted.
The template
Write the flake.nix directly from this skeleton instead of running
nix flake init: standard Nix now produces exactly this shape, but other
distributions (e.g. Determinate) ship different templates, and you would have
to rewrite the result anyway.
# file: flake.nix
{
description = "<project> development environment";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
};
outputs = inputs: {
# Enter with `nix develop`
devShells = builtins.mapAttrs (system: pkgs: {
default = pkgs.mkShell {
packages = [
pkgs.git
# ... toolchain picked from the table below
];
};
}) inputs.nixpkgs.legacyPackages;
};
}
Buildable packages follow the same pattern:
# Build with `nix build`
packages = builtins.mapAttrs (system: pkgs: {
my-tool = pkgs.callPackage ./my-tool.nix { };
default = inputs.self.packages.${system}.my-tool;
}) inputs.nixpkgs.legacyPackages;
Why this pattern and not flake-utils
Do not use flake-utils, eachDefaultSystem, or hand-written system lists:
builtins.mapAttrsoverinputs.nixpkgs.legacyPackageswalks every platform nixpkgs supports, so the flake automatically gains or loses platforms together with nixpkgs — no hardcoded list to go stale.- Every extra input inflates the
flake.lockof your flake and of every flake that depends on it. Projects commonly end up pinning dozens of redundant copies of flake-utils; zero inputs beyond nixpkgs avoids that entirely. legacyPackagesreuses thepkgsinstance the nixpkgs flake already built, so evaluation stays fast even in larger dependency trees. The name only means "not subject to the flat flake output schema" — the packages are current.
direnv integration
If the repo has no .envrc, add one next to the flake so direnv users get
the dev shell automatically on cd:
# file: .envrc
use flake
dotenv
Leave an existing .envrc alone (offer switching legacy use nix lines to
use flake while you're at it).
Tailoring the dev shell
Pick the toolchain from marker files at the repo root, then read the README if nothing matches:
| Marker | Packages |
|---|---|
Cargo.toml |
rustc cargo clippy rustfmt rust-analyzer |
go.mod |
go gotools gopls |
package.json |
nodejs, plus pnpm/yarn/bun matching the lockfile |
pyproject.toml/requirements.txt |
python3 uv |
*.csproj/*.sln |
dotnet-sdk |
mix.exs |
elixir |
composer.json |
php composer |
Gemfile |
ruby |
Always include git — the repo is a Git checkout. Add linters/formatters the
project configures (e.g. ruff, prettier, golangci-lint) and native
libraries that build scripts expect.
Language-specific notes:
- Rust: nixpkgs'
rustc/cargoare fine to start. If the repo pins an exact toolchain viarust-toolchain.tomland builds fail over version mismatch, reach for the communityrust-overlayinput rather than fighting nixpkgs — that's a real need, unlike platform enumeration. - Python: pip cannot install into nixpkgs' Python directly. Prefer
uvin the shell (uv venv && uv sync) so dependency management stays with the project's lockfile. For a Python package from nixpkgs to be importable, wire it viapython3.withPackagesinstead of listing it side-by-side. - If a
shell.nixordefault.nixalready exists, port its packages and envvars into the flake's dev shell instead of inventing a parallel set, and leave the old file in place unless the user asks to remove it.
If nixos-unstable currently breaks one of the toolchains you need (this
happens regularly on staging transitions), pinning to the latest stable
release branch (nixos-25.05) is the right call — say so in a brief comment
in the flake so readers know why.
Adding packages
For projects that should also expose a buildable package, put the derivation
in its own file and wire it up with callPackage inside the mapAttrs
block, as shown above. If your package must be visible inside pkgs
(because it overrides an existing package or other packages depend on it),
extend pkgs with an overlay instead:
packages = builtins.mapAttrs (
system: pkgs':
let
pkgs = pkgs'.extend (final: prev: {
my-tool = final.callPackage ./my-tool.nix { };
});
in
{
inherit (pkgs) my-tool;
default = pkgs.my-tool;
}
) inputs.nixpkgs.legacyPackages;
A side effect of the overlay route: the package also becomes available
through variants like pkgs.pkgsStatic and pkgs.pkgsCross.
Custom nixpkgs configuration
legacyPackages uses nixpkgs' default configuration. When you need
allowUnfree, CUDA support, or baked-in overlays, instantiate nixpkgs per
selected system yourself with this 5-line helper — still no extra inputs:
outputs =
inputs:
let
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
eachSystem =
systems: f:
builtins.foldl' (
a: s: a // builtins.mapAttrs (k: v: (a.${k} or { }) // { ${s} = v; }) (f s)
) { } systems;
in
eachSystem systems (
system:
let
pkgs = import inputs.nixpkgs {
inherit system;
config.allowUnfree = true;
};
in
{
packages.default = pkgs.hello;
devShells.default = pkgs.mkShell {
inputsFrom = [ inputs.self.packages.${system}.default ];
};
}
);
Decision guide:
- Default case →
mapAttrsoverlegacyPackages. - Package must live inside
pkgs→pkgs.extendoverlay. - Custom nixpkgs config needed →
eachSystemhelper above. - Reach for
flake-partsonly if you actually use its features, never just for platform enumeration.
Verification
Never hand back an unverified flake:
- Parse:
nix-instantiate --parse flake.nix - Evaluate:
nix flake show(lists all outputs across systems) - Enter:
nix develop --command true, or better, run the project's own build/test command through it:nix develop --command cargo build - Show the user what was added (
git status); do not commit unless asked. If the repo's README documents setup steps, a one-line pointer tonix developis usually appreciated — ask or mention it, don't silently rewrite docs.
Migrating away from flake-utils
If an existing flake uses flake-utils.lib.eachDefaultSystem or similar:
- Replace the helper with
builtins.mapAttrs ... inputs.nixpkgs.legacyPackages, keeping the body of the callback unchanged. - Delete the
flake-utilsinput frominputs. - Run
nix flake lockand confirmflake-utilsno longer appears inflake.lock. - Re-verify per the checklist above — output paths stay identical
(
.#packages.<system>.<name>), so nothing downstream breaks.