Flakes for Reproducible Dev Environments
Flakes for Reproducible Dev Environments
shell.nix plus <nixpkgs> is “whatever I last nix-channel --update’d.” The boring default is: flake.nix + committed flake.lock on nixos-26.05, devShells.${system}.default, nix develop.
Mental model
| Piece | Job |
|---|---|
inputs.nixpkgs.url |
Which train (nixos-26.05) |
flake.lock |
Exact rev + narHash |
devShells.${system}.default |
What nix develop enters |
| Pure eval | No undeclared files, no ambient NIX_PATH |
flake.nix → flake.lock → identical mkShell on every machine
nix flake update is a deliberate PR. Not a side effect of nix develop.
Worked examples
Case 1: Minimal flake shell
Save as flake.nix:
# flake.nix
{
description = "Desk service development shell";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.${system}.default = pkgs.mkShell {
packages = [ pkgs.go pkgs.gopls pkgs.golangci-lint ];
env.GOTOOLCHAIN = "local";
};
};
}nix flake lock
nix develop --command go env GOVERSION GOTOOLCHAINOutput (shape):
go1.24.x
local
Apple silicon: system = "aarch64-darwin"; or eachDefaultSystem (Case 3).
Case 2: Commit the lock
git add flake.nix flake.lock
nix flake metadata --json | jq -r '.locks.nodes.nixpkgs.locked.rev'Without flake.lock in git, CI generates a new one every job. That is a channel with extra steps.
git check-ignore -v flake.lock || echo 'lock is tracked (good)'If .gitignore has flake.lock, delete that line. The lock is the pin.
nix flake metadata --json | jq -r '.locks.nodes.nixpkgs.original.ref // .locks.nodes.nixpkgs.original.rev'You want nixos-26.05 (or a commit on that train), not nixpkgs-unstable.
Case 3: eachDefaultSystem
# flake.nix
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
forAll = nixpkgs.lib.genAttrs systems;
in
{
devShells = forAll (system:
let pkgs = nixpkgs.legacyPackages.${system}; in {
default = pkgs.mkShell { packages = [ pkgs.go pkgs.jq ]; };
});
};
}flake-utils is optional sugar. Explicit systems is easier to read.
Case 4: Update one input
nix flake update nixpkgs
git diff flake.lock
nix develop --command go env GOVERSIONRead the diff. If gcc moved, CI will compile. That is expected. Do not update in the same PR as a feature.
Case 5: nix flake check as the empty gate
checks.x86_64-linux.go-version = pkgs.runCommand "go-version" {
nativeBuildInputs = [ pkgs.go ];
} ''
go env GOVERSION > $out
'';nix flake checkLater chapters add real tests. Even this proves CI uses the lock.
The trap
The trap is gitignore flake.lock “because it churns.” Then there is no team pin.
The other trap is inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; for a production service. Unstable moves daily. 26.05 until you have an upgrade PR.
nix develop --impure to read $HOME is a smell. Declare the file as an input or keep it out of eval.
The boring rule
nixos-26.05+flake.lockin git.nix developfor the default shell.genAttrs/ explicit systems; do not hardcode onlyx86_64-linuxif the desk uses Macs.nix flake update nixpkgsin its own commit.GOTOOLCHAIN=localin Go shells so the Nixgowins.
Try this
jq -r '.nodes.nixpkgs.locked.rev' flake.lock.- Delete
flake.locklocally,nix develop, compare the new rev, restore from git. - Add
aarch64-linuxtosystems;nix flake show. nix flake update --commit-lock-fileand read the commit (then reset if you should not keep it).