Flakes and project devShells
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.lockIf 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 listnixpkgs#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
exitExample C — Metadata
nix flake metadata
nix flake showExample 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 initWrite flake.nix (start from Example A; trim systems if you want).
nix flake lock
nix flake show
nix develop -c hellogit 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)
- Enter
nix developonce online (populate store).
- Note
nix flake metadataresolved revisions.
- 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
- Why
flake.lockis mandatory in git
- Difference between
packagesanddevShells
- What
nix developuses by default
- Why registry
nixpkgs#≠ your flake’s pinned nixpkgs
Exercises
- Add a second named devShell (
devShells.${system}.tiny) with fewer packages.
- Use
followswith a second input (even a path stub) to practice wiring.
- Bump only
nixpkgswith selective update; read the lock diff.
- Break purity: leave a needed file untracked; observe failure;
git add.
- Export
metadata --jsonand record lockednarHash/rev.
- Add a trivial
packages.defaultandnix run.
- Document system portability: does a friend on another arch work?
- Compare closure of fat vs thin shell (
path-info).
- Write CONTRIBUTING.md: “always commit lock.”
- Replace registry experiments with project inputs only.
- Try
nix flake checkif you add a simple check later (optional).
- Sketch how
nixosConfigurationswill 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.nixwith nixpkgs 26.05 input
flake.lockcommitted
nix developprovides ≥2 real tools
nix flake showlists 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 |