Day 3 — Derivations, Modern CLI & First Flake

Updated

July 30, 2025

Day 3 — Derivations, Modern CLI & First Flake

Day 6 — Derivations as idea

Stage I · ~4h (theory-heavy)
Goal: Treat a derivation as a pure build plan: relate evaluation → .drv → store outputs; run a tiny build; read logs; explain sandbox and substitutes at a conceptual level.

Note

Deep stdenv phase mastery is Stage V. Today is the idea and one successful build loop.

Why this day exists

Without “derivation,” the store (Day 2) is a pile of directories and flakes are just JSON with extra steps. Derivations are the contracts that produce store paths.


Theory 1 — Derivation = build recipe with a promised output path

A derivation answers:

  1. What inputs (other store paths, sources)?
  2. What builder runs (usually bash + stdenv)?
  3. What environment (env vars, sandbox)?
  4. What output path(s) will hold results?

Informally:

derivation D
  inputs:  I1, I2, …
  builder: B
  → output path P = hash(inputs, builder, args, system, …)

In the common input-addressed model, the hash is about the plan, not the bytes of the result (contrast content-addressed—later).


Theory 2 — Eval vs realize (operational)

Phase Artifact Command vibes
Evaluate Nix values + derivation objects nix eval, instantiate
Low-level plan .drv file in the store nix derivation show
Realize Output path directories nix build
Run Binaries inside outputs nix run, ./result/bin/…

.drv is not the package

/nix/store/xxxx-hello-2.12.drv   ← plan
/nix/store/yyyy-hello-2.12       ← output (realized)

You ship and cache outputs; .drv explains how they were supposed to be built.


Theory 3 — mkDerivation mental model (lite)

Most software uses stdenv.mkDerivation:

stdenv.mkDerivation {
  pname = "mytool";
  version = "0.1.0";
  src = ./src;
  buildInputs = [ /* runtime/build deps */ ];
  # phases: unpack, patch, configure, build, install, fixup, …
}

For learning today, prefer trivial builders that avoid compilers:

pkgs.writeText "hello.txt" "hello from nix\n"

pkgs.writeShellScriptBin "greet" ''
  echo "hello from store script"
''

These still produce real store paths and teach the loop without a C toolchain fight.


Theory 4 — Sandbox: why builds are fenced

The build sandbox aims to make builds pure:

Restriction (typical Linux sandbox) Why
Limited filesystem view No sneaking /usr/lib deps
No network (usually) Downloads must be fixed-output derivations
Clean env Fewer “works on my machine” env vars
Only declared inputs visible Graph stays honest

Fixed-output derivations (FOD) — awareness

Fetchers (fetchurl, fetchFromGitHub) are special: they may use network but must declare a hash of the result. Wrong hash → fail. That is how purity reconciles with downloading sources.

fetchurl {
  url = "https://example.com/foo.tar.gz";
  sha256 = "…";  # wrong → error
}

Theory 5 — Substituters vs local build

Realization of path P:

1. Is P already in the local store? → done
2. Can a trusted substituter provide P? → download
3. Else build from .drv locally

Implications

  • First build can be “download a lot from cache.nixos.org”
  • Changing one input changes the hash → cache miss → compile
  • Trust is about signatures, not “HTTP has a file”

Theory 6 — Outputs and multiple outputs (preview)

A derivation can have several outputs (out, dev, doc, …):

P-out, P-dev, P-man

Closure size discipline later depends on not pulling dev into runtime. Today: know out is the default.


Theory 7 — What you inspect after a build

Tool Use
nix build … --print-out-paths Output path
ls result Symlink to output
nix log <path or installable> Build log
nix derivation show JSON-ish plan
nix path-info -rS Closure size

Worked examples bank

Example A — Build hello from nixpkgs

nix build nixpkgs#hello --print-out-paths
./result/bin/hello

Example B — Trivial local package via flake-less expr

trivial.nix:

let
  pkgs = import (builtins.getFlake "nixpkgs") { system = builtins.currentSystem; };
  # If getFlake is awkward offline, use: nix build -f '<nixpkgs>' hello  (literacy)
in
  pkgs.writeShellScriptBin "greet" ''
    echo "greetings from a derivation"
  ''

Modern simple approach with nix build and expression:

nix build --impure --expr 'with import (builtins.getFlake "nixpkgs") { }; writeText "t" "hi\n"'

Prefer the flake-based lab below for cleanliness.

Example C — Read derivation metadata

OUT=$(nix build nixpkgs#hello --print-out-paths)
nix derivation show nixpkgs#hello | head -c 2000
nix log "$OUT" 2>/dev/null || nix log nixpkgs#hello | tail

Example D — Sandbox failure story (conceptual)

Builder does: curl http://example.com/dep.so
→ network blocked (non-FOD)
→ build fails
→ declare dep as input or use fetchurl with hash

Lab 1 — Workspace + flake shell for builds

mkdir -p ~/lab/90daysofx/02-nixos/day06
cd ~/lab/90daysofx/02-nixos/day06

Create flake.nix (preview of Day 8—minimal):

{
  description = "day06 trivial derivations";

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

  outputs = { self, nixpkgs }:
    let
      system = "x86_64-linux"; # change if needed: aarch64-linux, aarch64-darwin, …
      pkgs = import nixpkgs { inherit system; };
    in {
      packages.${system} = {
        greet = pkgs.writeShellScriptBin "greet" ''
          echo "hello from day06 derivation"
        '';
        note = pkgs.writeText "day06-note.txt" ''
          Derivations plan store paths.
        '';
        default = self.packages.${system}.greet;
      };
    };
}

If your system is not x86_64-linux, edit system. On multi-arch learning, you can also use builtins.currentSystem in impure experiments—but pin explicitly when you can.

nix build .#greet
./result/bin/greet
nix build .#note
cat result   # may be the text file path content via result symlink

Commit flake.lock when generated.


Lab 2 — Inspect plan and log

nix build .#greet --print-out-paths
nix derivation show .#greet | head -n 80
nix log .#greet | tail -n 40

Journal:

  1. Output path hash/name
  2. Whether the “build” was essentially a write to the store
  3. One field you saw in derivation show that looks like an input

Lab 3 — Compare to nixpkgs hello closure

H=$(nix build nixpkgs#hello --print-out-paths)
G=$(nix build .#greet --print-out-paths)
echo "hello requisites: $(nix-store -q --requisites "$H" | wc -l)"
echo "greet requisites: $(nix-store -q --requisites "$G" | wc -l)"
nix path-info -rS "$H" | tail -3
nix path-info -rS "$G" | tail -3

Explain size difference in one sentence (runtime interpreter/libs vs tiny script).


Lab 4 — Break a FOD on purpose (optional)

nix build --expr 'let pkgs = import (builtins.getFlake "nixpkgs") { system = "x86_64-linux"; }; in pkgs.fetchurl { url = "https://example.com"; sha256 = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; }' 2>&1 | tail

You want a hash mismatch or fetch failure—not a success. Note the error class for later packaging days.


Lab 5 — Teach-back

Define without notes:

  1. Derivation
  2. .drv vs output path
  3. Why sandbox blocks network by default
  4. Substituter vs local build
  5. Why writeShellScriptBin is still a “real” package

Common gotchas

Symptom Theory
Wrong system in flake Outputs empty / wrong attr path
result pins GC root rm result when done experimenting
Edited files in store Immutable—change expression, rebuild
“Build” always compiles Substituters often download
Impure path surprises Unqualified imports / currentSystem
Darwin vs Linux scripts shebangs and system must match host

Checkpoint

  • Built a local trivial package via flake output
  • Ran the resulting binary/script
  • Inspected derivation show + log
  • Compared closure size to hello
  • Explained sandbox + FOD at awareness level
  • flake.lock present

Commit

cd ~/lab/90daysofx/02-nixos/day06
git init 2>/dev/null || true
git add .
git commit -m "day06: derivations idea trivial build logs"

Tomorrow

Day 7 — Modern CLI fluency. Make nix build / run / develop / shell / flake / path-info second nature—same store, better UX.


Day 7 — Modern CLI fluency

Stage I · ~4h (theory-heavy + drills)
Goal: Prefer the modern nix CLI for all daily tasks: build, run, develop, shell, flake inspection, store queries—and know which command matches which intent.

Why this day exists

Docs and blog posts mix eras. Muscle memory on the wrong CLI makes flakes feel harder than they are. Today is deliberate fluency so Days 8–10 feel like product work, not archaeology.


Theory 1 — One CLI, many intents

Intent Modern command Rough meaning
Build installable → result nix build Realize outputs
Run app without installing nix run Build/realize + exec
Project dev environment nix develop Shell with build inputs (flake devShell)
Ad-hoc package shell nix shell Temporary PATH with packages
Explore flake outputs nix flake show Schema of outputs
Update lock nix flake update Refresh inputs (careful)
Metadata / eval nix eval Evaluate expressions
Store facts nix path-info Size, refs, sigs
Search nix search Find packages
Profile (user env) nix profile Generational user installs
Format / check (later) nix fmt, nix flake check Quality gates

Classic cousins (nix-build, nix-shell, …) wait until Day 9.


Theory 2 — Installables: how you name what you want

Modern CLI takes installables:

Form Example Meaning
Flake output .#greet Current flake, package greet
Flake + attr .#packages.x86_64-linux.greet Fully spelled
Indirect flake nixpkgs#hello Registry/indirect nixpkgs
Git flake github:NixOS/nixpkgs/nixos-26.05#hello Explicit
Path flake ./myproj#default Local
File / expr -f file.nix, --expr '…' Non-flake (still useful)

# splits flake ref from attribute

nixpkgs#hello
   │       │
   │       └─ attribute path inside outputs (with defaults)
   └─ flake reference

Theory 3 — nix build deep enough for daily use

nix build .#greet
nix build .#greet --print-out-paths
nix build .#greet -o greet-result
nix build nixpkgs#hello
Flag Role
--print-out-paths Print store paths
-o name Name the result symlink
--no-link Realize without result
-L / --print-build-logs Stream logs
--rebuild Force rebuild (when applicable)

Discipline: temporary builds → --no-link or rm result to avoid GC root clutter (Day 2).


Theory 4 — nix run vs nix shell vs nix develop

nix run

nix run nixpkgs#hello
nix run .#greet
nix run nixpkgs#cowsay -- "moo"

Builds/downloads if needed, runs the package’s main program, does not require a permanent profile entry.

nix shell

nix shell nixpkgs#git nixpkgs#jq
# temporary shell with git + jq on PATH

Ad-hoc tools. Great for “I need jq once.”

nix develop

nix develop                 # .#devShells.default or similar
nix develop .#name

Enters the flake’s devShell—meant for project dependencies, compilers, env vars. Day 8 makes one.

Command Sticky? Typical use
run No One-shot CLI tools
shell Until exit Ad-hoc package mix
develop Until exit Project toolchain
profile install Yes User-global tools (use sparingly early)

Theory 5 — Flake subcommands

nix flake init          # scaffold (optional)
nix flake new mod       # template
nix flake show          # outputs tree
nix flake metadata      # resolved inputs, locks
nix flake check         # run checks (later quality)
nix flake update        # update all inputs
nix flake update nixpkgs  # selective update
nix flake lock          # refresh lock without full update semantics as needed

show vs metadata

Command Answers
show What outputs exist?
metadata What are inputs pinned to?

Theory 6 — Eval and debugging eval

nix eval .#packages.x86_64-linux.greet.name
nix eval --raw .#packages.x86_64-linux.greet
nix eval --json nixpkgs#hello.meta | head
nix repl
:lf .
:lf nixpkgs

REPL remains the best theory laboratory.


Theory 7 — Store queries you will reuse

nix path-info -Sh ./result
nix path-info -rS ./result | tail
nix store gc --dry-run   # careful; prefer dry concepts early
nix store delete …       # advanced; skip casually

nix-store -q still works; modern path-info is friendlier.


Theory 8 — Profiles without making them a lifestyle

nix profile list
nix profile install nixpkgs#jq
nix profile remove … 

This volume’s preference: project flakes + devShells over growing a huge imperative profile. Profiles are fine for a few personal tools; they are not a substitute for declarative hosts (Stage II).


Worked examples bank

Example A — Same package, three ways

nix run nixpkgs#hello
nix shell nixpkgs#hello -c hello
nix build nixpkgs#hello --print-out-paths

Example B — Flake introspection

cd ~/lab/90daysofx/02-nixos/day06   # or day07 copy
nix flake show
nix flake metadata

Example C — Logs streaming

nix build nixpkgs#hello -L

Example D — JSON for scripting

nix flake metadata --json | head -c 400
nix path-info --json ./result | head -c 400

Lab 1 — Cheat-sheet construction

mkdir -p ~/lab/90daysofx/02-nixos/day07
cd ~/lab/90daysofx/02-nixos/day07

Create CHEATSHEET.md and fill by running each command at least once:

build:    …
run:      …
shell:    …
develop:  … (may wait until day08 if no shell yet)
show:     …
metadata: …
eval:     …
path-info:…
search:   …

Paste the exact successful command lines you used.


Lab 2 — Copy day06 flake and operate only with modern CLI

cp -a ~/lab/90daysofx/02-nixos/day06/* ~/lab/90daysofx/02-nixos/day07/ 2>/dev/null || true
# or recreate minimal flake from day06

Tasks:

  1. nix flake show
  2. nix build .#greet --print-out-paths
  3. nix run .#greet
  4. nix path-info -rS ./result | tail
  5. rm -f result and rebuild with --no-link

Lab 3 — Ad-hoc nix shell workflow

nix shell nixpkgs#jq nixpkgs#ripgrep -c bash -c 'jq --version; rg --version'

Solve a tiny task: query a JSON blob with jq without installing jq into a profile permanently.


Lab 4 — Search + run loop

nix search nixpkgs cowsay | head
nix run nixpkgs#cowsay -- "modern cli"

Record the attribute name you used.


Lab 5 — Intent mapping drill (closed book)

Write the best command for each:

  1. Temporarily need ffmpeg
  2. See outputs of current flake
  3. Build package without leaving result
  4. Run project’s default app
  5. See what nixpkgs revision lock pins

Common gotchas

Symptom Theory
experimental features errors nix-command / flakes not enabled
Attribute not found Wrong system triple / output layout
nix run can’t find binary Package has no main program meta
Mixing nix-env -i habits Prefer run/shell/develop
flake show empty packages system key mismatch
Slow nix search First-time indexing/eval

Checkpoint

  • Cheat-sheet written from real command output
  • Can choose run vs shell vs develop correctly
  • Built with and without result link
  • Used flake show + metadata
  • Used path-info on a real path
  • No new dependency on classic CLI

Commit

cd ~/lab/90daysofx/02-nixos/day07
git init 2>/dev/null || true
git add .
git commit -m "day07: modern cli cheatsheet drills"

Tomorrow

Day 8 — First project flake. Author inputs / outputs / flake.lock properly with devShells.default for a real project directory.


Day 8 — First project flake

Stage I · ~4h (theory + project)
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 day exists

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 Stage II; project pinning comes now.


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.


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.


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 (Stage II)
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.


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" ];

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

Discover:

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

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.


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

Day 24 goes deeper; today: 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.


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.


Worked examples bank

Example A — Minimal full flake

{
  description = "day08 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

Lab 1 — Create the project

mkdir -p ~/lab/90daysofx/02-nixos/day08/myproject
cd ~/lab/90daysofx/02-nixos/day08/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 "day08: 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 of gate)

  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

Common gotchas

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

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

Commit

cd ~/lab/90daysofx/02-nixos/day08/myproject
git add .
git commit -m "day08: first project flake devShell lock"

Tomorrow

Day 9 — Classic CLI literacy. Map old nix-build / nix-shell / channels onto modern commands once so old docs stop scaring you.