Flakes for Reproducible Dev Environments

Updated

September 12, 2026

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 GOTOOLCHAIN

Output (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 GOVERSION

Read 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 check

Later 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.lock in git.
  • nix develop for the default shell.
  • genAttrs / explicit systems; do not hardcode only x86_64-linux if the desk uses Macs.
  • nix flake update nixpkgs in its own commit.
  • GOTOOLCHAIN=local in Go shells so the Nix go wins.

Try this

  1. jq -r '.nodes.nixpkgs.locked.rev' flake.lock.
  2. Delete flake.lock locally, nix develop, compare the new rev, restore from git.
  3. Add aarch64-linux to systems; nix flake show.
  4. nix flake update --commit-lock-file and read the commit (then reset if you should not keep it).