Day 45 — stdenv phases

Updated

July 30, 2026

Day 45 — stdenv phases

Stage V · ~4h (lab-heavy)
Goal: Package a small C or shell tool with stdenv.mkDerivation, name every default phase, and know when to override hooks vs rewrite the whole build.

Note

Stage V starts after you have run a real host. Packaging now is craft, not theory before you felt closure size.

Why this day exists

Most “how do I package X?” questions collapse into one skill: drive stdenv phases deliberately. Language helpers (buildGoModule, buildPythonPackage, …) are thin wrappers on the same lifecycle. If you cannot read a failing phase log, every later day in Stage V will feel random.


Theory 1 — What stdenv.mkDerivation actually is

A derivation is a pure build plan: inputs → sandbox → outputs under /nix/store.
stdenv.mkDerivation fills that plan with a bash driver (genericBuild) and a default toolchain (compiler, coreutils, patch, make, …).

{ stdenv, fetchurl }:
stdenv.mkDerivation rec {
  pname = "hello";
  version = "2.12.1";
  src = fetchurl {
    url = "mirror://gnu/hello/hello-${version}.tar.gz";
    hash = "sha256-…"; # replace with real FOD hash after first fail
  };
  meta = {
    description = "GNU Hello prints a friendly greeting";
    license = lib.licenses.gpl3Plus;
  };
}
Concept Meaning
pname / version Human labels; feed name as ${pname}-${version} by default
src Input path (usually a fixed-output fetcher)
buildInputs Host libraries linked into the result (runtime + link time on native)
nativeBuildInputs Tools that run on the build machine (compilers, cmake, pkg-config)
propagated* Rare; pollutes dependents—prefer explicit deps

buildInputs vs nativeBuildInputs (cross-aware)

Attr Role Cross example
nativeBuildInputs Execute during build pkg-config, cmake, host gcc when cross-compiling
buildInputs Link / use as target libraries openssl, zlib for the target system
depsBuildBuild Advanced bootstrap Tools for the build→build hop

For native-only packaging you can get away with sloppy placement; cross and static punish it. Prefer correct classification from day one.


Theory 2 — Default phase order

genericBuild runs roughly:

unpackPhase
patchPhase
updateAutotoolsGnuConfigScriptsPhase   # often invisible
configurePhase
buildPhase
checkPhase          # if doCheck = true
installPhase
fixupPhase
installCheckPhase   # if doInstallCheck = true
distPhase           # rare for binary packages

Hooks fire before and after many phases (preConfigure, postInstall, …). Prefer hooks over replacing an entire phase when you only need a few lines.

Phase responsibilities

Phase Default intent Common overrides
unpack Extract src into $sourceRoot sourceRoot, setSourceRoot, custom unpackPhase for flat files
patch Apply patches list prePatch to fix line endings
configure ./configure --prefix=$out if script exists cmakeConfigurePhase, meson, or dontConfigure
build make buildPhase = "go build …" for tiny tools (prefer language helpers)
check make check Gate with doCheck = true
install make install Copy binaries into $out/bin
fixup Strip, compress man, move docs, patch shebangs dontStrip, separateDebugInfo

Theory 3 — Important environment variables in the sandbox

During the build, the driver exports paths you should treat as API:

# always available in phases
echo "$out"          # primary output
echo "$src"          # unpacked-from input
echo "$NIX_BUILD_TOP"
echo "$sourceRoot"

Multiple outputs (Day 50) add $dev, $man, $doc, …. For today, stick to $out.

Shebang and RPATH fixups happen in fixupPhase / patchShebangs. If a script still points at /usr/bin/env incorrectly, you usually forgot nativeBuildInputs for the interpreter or skipped fixup.


Theory 4 — Minimal patterns you will reuse forever

Shell-only “package” (no compile)

{ stdenv, lib, bash }:
stdenv.mkDerivation {
  pname = "greet";
  version = "0.1.0";
  dontUnpack = true;
  nativeBuildInputs = [ ];
  installPhase = ''
    runHook preInstall
    mkdir -p $out/bin
    cat > $out/bin/greet <<'EOF'
    #!${bash}/bin/bash
    echo "hello from Stage V"
    EOF
    chmod +x $out/bin/greet
    runHook postInstall
  '';
  meta = {
    description = "Trivial greet script packaged with stdenv";
    license = lib.licenses.mit;
    mainProgram = "greet";
  };
}

Autotools / simple C

{ stdenv, fetchurl, lib }:
stdenv.mkDerivation rec {
  pname = "hello";
  version = "2.12.1";
  src = fetchurl {
    url = "mirror://gnu/hello/hello-${version}.tar.gz";
    hash = "sha256-jZkUKv2SV28wsM18tCqNxoCZmLxdYH2Idh9RLibH2yA=";
  };
  doCheck = true;
  meta = {
    homepage = "https://www.gnu.org/software/hello/";
    license = lib.licenses.gpl3Plus;
    mainProgram = "hello";
  };
}

Single C file without autotools

{ stdenv, lib }:
stdenv.mkDerivation {
  pname = "addone";
  version = "0.1.0";
  src = ./src; # directory with addone.c
  buildPhase = ''
    runHook preBuild
    $CC addone.c -O2 -o addone
    runHook postBuild
  '';
  installPhase = ''
    runHook preInstall
    install -Dm755 addone $out/bin/addone
    runHook postInstall
  '';
  meta = {
    description = "Adds one to an integer argument";
    license = lib.licenses.mit;
    mainProgram = "addone";
  };
}

Always call runHook pre* / runHook post* when you override a phase so preInstall hooks from dependencies still run.


Worked example — flake that exports the package

# flake.nix
{
  description = "Day 45 — stdenv packaging lab";

  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 (
        import nixpkgs { inherit system; }
      ));
    in {
      packages = forAllSystems (pkgs: {
        default = pkgs.callPackage ./pkgs/greet.nix { };
        greet = pkgs.callPackage ./pkgs/greet.nix { };
        hello = pkgs.hello; # control sample
      });

      checks = forAllSystems (pkgs: {
        greet-runs = pkgs.runCommand "greet-runs" {
          buildInputs = [ self.packages.${pkgs.system}.greet ];
        } ''
          greet | grep -q "Stage V"
          touch $out
        '';
      });
    };
}
nix build .#greet
./result/bin/greet
nix log ./result     # if build failed earlier
nix flake check

Lab — multi-step

Suggested workspace: ~/lab/90daysofx/02-nixos/day45

Step 1 — Scaffold

mkdir -p ~/lab/90daysofx/02-nixos/day45/{pkgs,src}
cd ~/lab/90daysofx/02-nixos/day45

Create src/addone.c:

#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv) {
  if (argc != 2) { fprintf(stderr, "usage: %s N\n", argv[0]); return 1; }
  printf("%d\n", atoi(argv[1]) + 1);
  return 0;
}

Step 2 — Write three packages

  1. pkgs/greet.nix — shell script package
  2. pkgs/addone.nix — single-file C
  3. Optional: package hello yourself with fetchurl (compare to pkgs.hello)

Step 3 — Break phases on purpose

In addone.nix, set temporarily:

installPhase = "false"; # fail install

Build, read the log, note which phase failed:

nix build .#addone --print-build-logs || true
# restore installPhase with runHook wrappers

Step 4 — Flip input kinds

Move a tool between buildInputs and nativeBuildInputs. On native Linux both may “work”; write one sentence on why native is still correct for compilers.

Step 5 — Phase introspection

nix develop .#default   # if you add a shell with the package
# or
nix build .#greet -L
nix path-info -r ./result | head

Document: unpack → build → install → fixup for each of your packages (what actually ran).

Step 6 — Host consumption (optional but good)

Wire environment.systemPackages = [ inputs.self.packages.${system}.greet ]; on a lab host or run via nix shell .#greet.


Common gotchas

Symptom / mistake What to do
builder for '…' failed with exit code 1 with no clue Rebuild with -L / --print-build-logs; jump to last phase name in log
install: missing destination Create $out/bin before install; use install -Dm755
Binary runs in build sandbox but not after install Forgot install into $out; or RPATH/interpreter wrong—check ldd / file
patchShebangs left bad paths Ensure interpreter package is a build input; call patchShebangs $out
Overrode buildPhase without runHook Add runHook preBuild / postBuild
Used buildInputs for cmake/pkg-config Prefer nativeBuildInputs
src is a single file, unpack fails unpackPhase = "cp $src ./foo.c"; sourceRoot = "."; or dontUnpack + copy in install
Name collisions / impure timestamps in C Avoid embedding timestamps; prefer -D defines from version only

Checkpoint

  • Can list default phase order from memory
  • greet and addone build with nix build
  • At least one intentional phase failure diagnosed from logs
  • nativeBuildInputs vs buildInputs explained in your notes
  • Phases documented for each package

Commit

git add .
git commit -m "day45: stdenv phases — greet + addone packages"

Write three personal gotchas before continuing.

Tomorrow

Day 46 — fetchers & FODs: hashes, fetchFromGitHub, and the mismatch workflow that keeps the store honest.