Project 1: Hello CLI derivation

Updated

July 30, 2026

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 $out and why the store path hash changed when you edited the script.
  • Why callPackage injects stdenv and lib.
  • Why nix path-info -r lists 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 init

Step 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.sh
Note

A 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
        '';
      });
    };
}
Tip

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-runs

Record in NOTES.md:

  1. Full store path of ./result (resolve symlink).
  2. Why more than one path appears in -r.
  3. 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 build produces ./result/bin/hello-cli-lab (or your chosen name).
  • nix run prints the hello line.
  • flake.lock exists and is committed.
  • You can point at the store path and explain $out/bin.
  • NOTES.md has one paragraph: phases skipped and why.
  • lib.cleanSource (or equivalent) is used, or you documented why not.

Stretch goals

  1. Add a trivial installCheckPhase / doInstallCheck that runs the binary.
  2. Multi-output experiment: put a README in $out/share/doc/... (still one output is fine).
  3. Build on a second system attribute (remote builder or second machine).
  4. Replace installPhase body with install -D one-liner and confirm hooks still matter.
  5. Add formatter / nix fmt later; not required for Project 1 green.

Checkpoint commit message (example)

project-01: package hello-cli-lab with stdenv and flake apps

Next: Project 2 — Script tool with runtime deps.