Flakes and project devShells

Updated

July 30, 2026

Flakes and project devShells

Goal: Author a real project flake with pinned inputs, clear outputs, committed flake.lock, and a working devShells.default—before any NixOS host work.

Why this chapter matters

Flakes are the modern collaboration unit: reproducible inputs + named outputs. A devShell is the highest-value first output—shared tools without polluting a global profile. OS configuration comes in the NixOS host part; project pinning comes now.

Ops motivation: “works on my machine” dies when flake.lock is the shared truth. Teams that skip locks reinvent channel drift with extra steps.


Theory 1 — What a flake is

A flake is a directory (usually a git tree) with:

File Role
flake.nix Declarative inputs + outputs function
flake.lock Exact pinned revisions/hashes of inputs

Evaluation model

flake.nix outputs = inputs: { … }
                 ↓
        lock pins input trees
                 ↓
     outputs provide packages, shells, apps, nixosConfigurations, …

Punchline: without the lock file, “same flake.nix” is not the same system.

Baseline for this book

inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";

Nix 2.34.x + flakes experimental features enabled (Why Nix exists chapter).


Theory 2 — inputs

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
    # optional:
    # flake-utils.url = "github:numtide/flake-utils";
  };
}

Common URL forms

URL Meaning
github:Owner/Repo Default branch (avoid for pins—use lock)
github:Owner/Repo/ref Branch/tag/rev
git+https://… Arbitrary git
path:./vendor/x Local path input

Following transitive inputs

inputs.foo.inputs.nixpkgs.follows = "nixpkgs";

Keeps a single nixpkgs when dependencies would otherwise multiply.

Input types (awareness)

Flakes can depend on other flakes, non-flake sources (with care), and path inputs. Prefer flake-native inputs for clarity.


Theory 3 — outputs

outputs = { self, nixpkgs, ... }@inputs:
  let
    system = "x86_64-linux";
    pkgs = import nixpkgs { inherit system; };
  in {
    packages.${system}.default = pkgs.hello;
    devShells.${system}.default = pkgs.mkShell {
      packages = [ pkgs.git pkgs.hello pkgs.jq ];
    };
  };

Output schema (common)

Output Purpose
packages.<system>.<name> Buildable packages
devShells.<system>.<name> nix develop environments
apps.<system>.<name> nix run apps
formatter.<system> nix fmt
checks.<system>.<name> nix flake check
nixosConfigurations.<name> NixOS hosts (host part)
overlays.default Package overlays (later)

self

self is the flake’s own source (after copy to store). Used for src = self; packaging and referring to other outputs: self.packages.${system}.foo.

Naming default

Output Default behavior
packages.<sys>.default nix build / nix run without attr
devShells.<sys>.default nix develop without attr

Theory 4 — Systems and portability

Hard-coding one system is fine for a first lab. Better patterns later: flake-utils, flake-parts, or explicit map over systems.

systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];

In this chapter: pick your system correctly or multi-define the same shell for two systems if you dual-boot.

Discover:

nix eval --impure --expr 'builtins.currentSystem'

genAttrs pattern

forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);

Theory 5 — mkShell / mkShellNoCC

pkgs.mkShell {
  packages = [ pkgs.go pkgs.git ];
  shellHook = ''
    echo "welcome to the dev shell"
    export DEMO=1
  '';
}
Field Role
packages / nativeBuildInputs Tools on PATH
buildInputs Libraries (more package-oriented)
shellHook Runs on enter

Prefer putting project tools here, not every CLI you like globally.

mkShellNoCC

When you do not need a C toolchain, mkShellNoCC can shrink closure—useful for pure scripting shells.


Theory 6 — Lock discipline (intro)

nix flake lock          # ensure lock exists/up to date with urls
nix flake update        # bump inputs (all)
nix flake update nixpkgs  # bump one input
Rule Why
Commit flake.lock Reproducibility
Review lock diffs Security + surprise upgrades
Selective update Smaller change sets
CI uses locked revs Bit-identical intent

The lock discipline chapter (Home and flake layout part) goes deeper; here: lock must exist and be committed.


Theory 7 — Flakes and git

Flakes generally want a git repository. Untracked files may be invisible to evaluation depending on setup.

git init
git add flake.nix
# after first lock:
git add flake.lock

If something “missing,” check git status—not only the filesystem.

Dirty tree semantics

Flakes may copy a clean view of tracked files. Untracked extra.nix will not exist in eval—by design for purity.


Theory 8 — Registries (light awareness)

nix registry list

nixpkgs#hello resolves via registry → often a floating ref until you pin your project’s own input. Project flakes should not rely on registry drift; they declare inputs.nixpkgs.url.

Reference Pin quality
Registry nixpkgs# Convenience; can drift
Project input + lock Collaboration default

Theory 9 — Apps vs packages (optional clarity)

apps.${system}.default = {
  type = "app";
  program = "${self.packages.${system}.greet}/bin/greet";
};

Often nix run on a package works via meta.mainProgram. Apps make the entrypoint explicit.


Theory 10 — What not to put in a first flake

Avoid early Why
Entire NixOS config Host part
Secrets Store readability
Unrelated global tools dump Closure bloat
Unlocked floating inputs Drift

Worked examples bank

Example A — Minimal full flake

{
  description = "project shell";

  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";

  outputs = { self, nixpkgs }:
    let
      systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
      forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
    in {
      devShells = forAllSystems (system:
        let pkgs = import nixpkgs { inherit system; };
        in {
          default = pkgs.mkShell {
            packages = with pkgs; [ git hello jq ];
          };
        });
    };
}

Example B — Enter and verify

nix develop
hello
git --version
jq --version
exit

Example C — Metadata

nix flake metadata
nix flake show

Example D — Non-interactive CI style

nix develop -c bash -lc 'git --version && jq --version'

Example E — Package + shell together

packages.${system}.greet = pkgs.writeShellScriptBin "greet" ''echo hi'';
devShells.${system}.default = pkgs.mkShell {
  packages = [ self.packages.${system}.greet pkgs.git ];
};

Lab 1 — Create the project

mkdir -p ~/lab/nixos-book/concepts/flakes/myproject
cd ~/lab/nixos-book/concepts/flakes/myproject
git init

Write flake.nix (start from Example A; trim systems if you want).

nix flake lock
nix flake show
nix develop -c hello
git add flake.nix flake.lock
git commit -m "initial project flake with devShell"

Lab 2 — Make it your project

Add tools you actually want for a small project (pick a lane):

Lane Example packages
Go go, gopls, git
Rust rustc, cargo, rust-analyzer
Python python3, uv or poetry
Ops jq, ripgrep, kubectl (if desired)

Edit packages = [ … ], re-enter shell, verify versions.

Add a trivial project file README.md describing how to enter the shell.


Lab 3 — shellHook and env

shellHook = ''
  export PROJECT_ROOT="$PWD"
  echo "devShell ready for $PROJECT_ROOT"
'';
nix develop -c bash -c 'echo $PROJECT_ROOT'

Lab 4 — Offline-capable after populate (preview)

  1. Enter nix develop once online (populate store).
  2. Note nix flake metadata resolved revisions.
  3. Optional: airplane mode / disconnect and nix develop --offline (or equivalent) if supported in your setup.

Journal what still requires network (usually nothing if all paths realized).


Lab 5 — Teach-back

  1. Why flake.lock is mandatory in git
  2. Difference between packages and devShells
  3. What nix develop uses by default
  4. Why registry nixpkgs# ≠ your flake’s pinned nixpkgs

Exercises

  1. Add a second named devShell (devShells.${system}.tiny) with fewer packages.
  2. Use follows with a second input (even a path stub) to practice wiring.
  3. Bump only nixpkgs with selective update; read the lock diff.
  4. Break purity: leave a needed file untracked; observe failure; git add.
  5. Export metadata --json and record locked narHash/rev.
  6. Add a trivial packages.default and nix run.
  7. Document system portability: does a friend on another arch work?
  8. Compare closure of fat vs thin shell (path-info).
  9. Write CONTRIBUTING.md: “always commit lock.”
  10. Replace registry experiments with project inputs only.
  11. Try nix flake check if you add a simple check later (optional).
  12. Sketch how nixosConfigurations will hang off the same outputs function (host part).

Common pitfalls

Symptom Theory
File not found in flake eval Not git-added
Wrong system attr devShells.<system> mismatch
nix develop empty No default shell
Divergent tools across machines Lock not committed / not pulled
Huge shell closure Too many packages; split shells later
Dirty tree warnings Commit or understand copy semantics
Floating input without lock Non-reproducible
Secrets in flake source Store may expose them

Checkpoint

  • flake.nix with nixpkgs 26.05 input
  • flake.lock committed
  • nix develop provides ≥2 real tools
  • nix flake show lists devShell
  • README explains entry
  • Understand lock vs registry
  • Multi-system or correctly single-system for your machine

Further depth (this book)

Topic Chapter / part
Classic ↔︎ modern map Classic CLI literacy
Host flakes Host: Flake-defined hosts
Lock discipline deep Home and flake layout part
flake-parts, checks Home and flake layout part
Packaging src = self Packaging part