Project 1: Hello CLI derivation
Project 1 — Package a tiny CLI with stdenv.mkDerivation
Goal: Write a reproducible derivation for a small shell or C “hello” tool, expose it from a flake, and run it via nix build / nix run.
| Field | Value |
|---|---|
| Baseline | Nix 2.34, nixpkgs nixos-26.05 |
| Time | ~1–2 hours |
| Depends on | the derivations chapter–8 (derivations/flake), the stdenv phases chapter (phases) helpful |
| Artifact | packages.<system>.default + working apps optional |
Why this project exists
If you only install packages from nixpkgs, you never own the build graph. This is the smallest honest package: no network in the build, no language ecosystem, no overlay tricks—just src → $out/bin.
After this project you should be able to say, without hand-waving:
- What lands in
$outand why the store path hash changed when you edited the script.
- Why
callPackageinjectsstdenvandlib.
- Why
nix path-info -rlists more than one path (the closure).
Prerequisites
- Flakes enabled (
experimental-features = nix-command flakes).
- Comfortable with
nix build,nix run,nix flake metadata.
- Optional: a C compiler via stdenv (already in the sandbox for C variant).
Theory
Derivation vs package vs app
| Term | Meaning in this lab |
|---|---|
| Derivation | Build plan (inputs + builder) realized into a store path |
| Package | Usually the derivation’s primary output ($out) |
Flake packages |
Attrset of packages per system |
Flake apps |
Named runnable (type = "app" + program absolute path) |
Phases you will skip on purpose
Shell-only packaging often sets:
dontConfigure = true;
dontBuild = true;That is honest: there is nothing to ./configure or make. You still run installPhase (and hooks). Prefer runHook preInstall / postInstall so later overlays and stdenv hooks compose.
$out is the contract
Everything a dependent might need must appear under $out (here: $out/bin/hello-cli-lab). Scripts that assume files next to the source tree outside $out will break for users of your package.
callPackage dependency injection
pkgs.callPackage ./pkg.nix { }opens pkg.nix as a function { stdenv, lib }: … and fills arguments from pkgs. You rarely pass stdenv by hand.
Purity / sandbox
The build sandbox blocks network by default. Project 1 must not need fetchurl if src = ./.. That keeps the first win fast and debuggable.
Lab tree
~/lab/nixos-projects/hello-cli/
flake.nix
flake.lock
pkg.nix
hello.sh # primary path in this chapter
hello.c # optional stretch
NOTES.md # your one-paragraph phase note
mkdir -p ~/lab/nixos-projects/hello-cli
cd ~/lab/nixos-projects/hello-cli
git initStep 1 — Source: shell tool
hello.sh:
#!/usr/bin/env bash
set -euo pipefail
echo "hello from $(basename "$0") — Nix derivation lab"Make it executable in the repo for local smoke tests (the install step will set modes in the store either way):
chmod +x hello.sh
./hello.shA working script on your PATH is not a Nix package yet. The derivation must install a copy into $out/bin.
Step 2 — Derivation
pkg.nix:
{ stdenv, lib }:
stdenv.mkDerivation {
pname = "hello-cli-lab";
version = "0.1.0";
src = ./.;
dontConfigure = true;
dontBuild = true;
installPhase = ''
runHook preInstall
mkdir -p $out/bin
install -m755 hello.sh $out/bin/hello-cli-lab
runHook postInstall
'';
meta = with lib; {
description = "Tiny lab CLI packaged with stdenv";
license = licenses.mit;
platforms = platforms.all;
mainProgram = "hello-cli-lab";
};
}Optional: clean the source filter
Without filtering, dirty files (.git, result, editor swap) can change hashes or bloat src. Prefer:
src = lib.cleanSource ./.;or a tighter lib.fileset / builtins.path filter once you outgrow cleanSource.
Step 3 — Flake
flake.nix:
{
description = "hello-cli-lab — Project 1";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
forAll = nixpkgs.lib.genAttrs systems;
in {
packages = forAll (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in {
default = pkgs.callPackage ./pkg.nix { };
hello-cli-lab = pkgs.callPackage ./pkg.nix { };
});
apps = forAll (system: {
default = {
type = "app";
program = "${self.packages.${system}.default}/bin/hello-cli-lab";
};
});
checks = forAll (system: {
hello-runs = nixpkgs.legacyPackages.${system}.runCommand "hello-cli-check" {
nativeBuildInputs = [ self.packages.${system}.default ];
} ''
hello-cli-lab | grep -q "Nix derivation lab"
touch $out
'';
});
};
}meta.mainProgram helps nix run discover the binary even without a custom apps output on newer nixpkgs helpers. Keeping an explicit apps.default is still clear for labs.
Step 4 — Build, run, inspect
nix flake lock
nix build -L
./result/bin/hello-cli-lab
nix run
nix run .#hello-cli-lab
# closure: what else is needed to run this?
nix path-info -rS ./result | tail -n 20
nix path-info -Sh ./result
# optional check output
nix build .#checks.$(nix eval --raw --impure --expr builtins.currentSystem).hello-runsRecord in NOTES.md:
- Full store path of
./result(resolve symlink).
- Why more than one path appears in
-r.
- Phases you skipped and why.
readlink -f result
nix-store -q --references "$(readlink -f result)"Step 5 — C variant (stretch, same flake)
Optional: hello.c + buildPhase with $CC hello.c -o hello-cli-c, install to $out/bin, expose as packages.*.hello-c. Compare nix path-info -Sh vs the shell package (bash vs dynamic linker story).
Theory checkpoints (self-test)
| Topic | You should explain out loud |
|---|---|
$out |
Install prefix for this package’s primary output |
pname / version |
How default name is formed |
callPackage |
Dependency injection of stdenv / lib |
| Closure | Runtime dependency graph of store paths |
| Purity | Why the sandbox blocks network by default |
result |
GC root symlink from nix build |
dontBuild |
Skipping a phase vs empty buildPhase |
Pitfalls
| Symptom | Likely cause | Fix |
|---|---|---|
permission denied running store binary |
Mode not set in install | install -m755 or chmod +x in installPhase |
| Hash changes every commit | Unfiltered src = ./. includes junk |
lib.cleanSource / fileset |
nix run cannot find binary |
Wrong path in apps or missing mainProgram |
Align program name with $out/bin/... |
Build sees no hello.sh |
Wrong src root or file not committed |
Check tree; src must contain the script |
| Darwin vs Linux surprises | platforms too narrow/wide |
Set meta.platforms honestly; test the OS you claim |
| Infinite recursion in flake | self used before packages defined incorrectly |
Keep callPackage on pkgs, not circular self.packages in same attr |
Acceptance criteria
nix buildproduces./result/bin/hello-cli-lab(or your chosen name).
nix runprints the hello line.
flake.lockexists and is committed.
- You can point at the store path and explain
$out/bin.
NOTES.mdhas one paragraph: phases skipped and why.
lib.cleanSource(or equivalent) is used, or you documented why not.
Stretch goals
- Add a trivial
installCheckPhase/doInstallCheckthat runs the binary.
- Multi-output experiment: put a README in
$out/share/doc/...(still one output is fine).
- Build on a second system attribute (remote builder or second machine).
- Replace
installPhasebody withinstall -Done-liner and confirm hooks still matter.
- Add
formatter/nix fmtlater; not required for Project 1 green.
Checkpoint commit message (example)
project-01: package hello-cli-lab with stdenv and flake apps