stdenv and build phases

Updated

July 30, 2026

stdenv and build phases

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

Baseline: Nix 2.34 / nixpkgs 26.05, flakes-first. Language helpers (buildGoModule, …) wrap the same lifecycle—master phases here first.

Why it matters

Most “how do I package X?” questions collapse into one skill: drive stdenv phases deliberately. Language helpers are thin wrappers on the same lifecycle. If you cannot read a failing phase log, every later packaging chapter will feel random.

You have already consumed packages on a host. Packaging craft is producing them: pure plans, sandboxed builds, store outputs you can reason about.


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, lib }:
stdenv.mkDerivation rec {
  pname = "hello";
  version = "2.12.1";
  src = fetchurl {
    url = "mirror://gnu/hello/hello-${version}.tar.gz";
    hash = "sha256-jZkUKv2SV28wsM18tCqNxoCZmLxdYH2Idh9RLibH2yA=";
  };
  meta = {
    description = "GNU Hello prints a friendly greeting";
    license = lib.licenses.gpl3Plus;
    mainProgram = "hello";
  };
}
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
meta Description, license, platforms, mainProgram

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
depsBuildTarget Cross edge cases See nixpkgs cross docs

For native-only packaging you can get away with sloppy placement; cross and static punish it. Prefer correct classification from the first package you write.

Evaluation vs realization

nix eval / callPackage  →  derivation (.drv)
nix build / realize     →  /nix/store/… outputs

Phase scripts run at realization. A typo in a phase string fails the build; a typo in attribute structure may fail at eval. Know which side you are on before staring at compiler errors that never ran.


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 Custom compile; prefer language helpers for Go/Rust/…
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

Hooks API (muscle memory)

When you replace a phase, call hooks so setup hooks from dependencies still run:

runHook preBuild
# your commands
runHook postBuild
Hook Fires
preUnpack / postUnpack Around unpack
prePatch / postPatch Around patches
preConfigure / postConfigure Around configure
preBuild / postBuild Around build
preInstall / postInstall Around install
preFixup / postFixup Around fixup

Skipping runHook is a silent footgun: wrapProgram hooks, Python path setup, and many language helpers never run.


Theory 3 — Important environment variables in the sandbox

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

echo "$out"           # primary output
echo "$src"           # path to source input
echo "$NIX_BUILD_TOP" # temp build directory
echo "$sourceRoot"    # directory after unpack
echo "$PWD"           # usually $NIX_BUILD_TOP/$sourceRoot during build
echo "$CC" "$CXX"     # stdenv compilers
echo "$NIX_CFLAGS_COMPILE"  # injected flags

Multiple outputs (later chapter) add $dev, $man, $doc, …. In this chapter, 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 (dontFixup = true is rarely what you want).

Flags that skip work

Attr Effect
dontUnpack Skip unpack (inline sources / copy yourself)
dontConfigure Skip configure
dontBuild Skip build
dontInstall Almost never—you need $out populated
dontFixup Skip strip/shebang; debug only
doCheck Enable checkPhase
enableParallelBuilding make -j style parallelism

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;
  installPhase = ''
    runHook preInstall
    mkdir -p $out/bin
    cat > $out/bin/greet <<'EOF'
    #!${bash}/bin/bash
    echo "hello from packaging craft"
    EOF
    chmod +x $out/bin/greet
    runHook postInstall
  '';
  meta = {
    description = "Trivial greet script packaged with stdenv";
    license = lib.licenses.mit;
    mainProgram = "greet";
  };
}

Note the ${bash}/bin/bash in the shebang: absolute store path, pure and fixup-friendly.

Autotools / simple C (upstream tarball)

{ stdenv, fetchurl, lib }:
stdenv.mkDerivation rec {
  pname = "hello";
  version = "2.12.1";
  src = fetchurl {
    url = "https://ftp.gnu.org/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";
  };
}

Flat single-file source (unpack quirks)

{ stdenv, lib }:
stdenv.mkDerivation {
  pname = "note";
  version = "0.1.0";
  src = ./note.sh;
  dontUnpack = true;
  installPhase = ''
    runHook preInstall
    install -Dm755 $src $out/bin/note
    runHook postInstall
  '';
  meta = {
    mainProgram = "note";
    license = lib.licenses.mit;
  };
}

Or keep unpack and set:

unpackPhase = ''
  cp $src ./note.sh
  chmod +x ./note.sh
  sourceRoot=.
'';

Theory 5 — finalAttrs vs rec

Modern nixpkgs prefers finalAttrs over rec for self-reference:

stdenv.mkDerivation (finalAttrs: {
  pname = "cooltool";
  version = "1.2.3";
  src = fetchurl {
    url = "https://example.com/${finalAttrs.pname}-${finalAttrs.version}.tar.gz";
    hash = "sha256-…";
  };
})
Style Risk
rec { version = …; name = "${pname}-${version}"; } Easy to shadow; override surprises
finalAttrs: Overrides of version update dependent attrs cleanly

Use either in lab; prefer finalAttrs when shaping packages for upstream later.


Theory 6 — What fixup actually does (short list)

fixupPhase (simplified mental model):

  1. Move docs/man if multi-out helpers apply
  2. Strip binaries (unless dontStrip)
  3. patchShebangs on scripts under $out
  4. Compress man pages
  5. Record references for the runtime closure

If a binary works in the build directory but fails after install, check:

  • Did you install into $out?
  • Did RPATH / interpreter need patchelf (usually automatic for stdenv ELF)?
  • Did you set dontFixup?
file result/bin/addone
ldd result/bin/addone   # Linux

Worked example — flake that exports packages

# flake.nix
{
  description = "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 { };
        addone = pkgs.callPackage ./pkgs/addone.nix { };
        hello = pkgs.hello; # control sample from nixpkgs
      });

      checks = forAllSystems (pkgs: {
        greet-runs = pkgs.runCommand "greet-runs" {
          nativeBuildInputs = [ self.packages.${pkgs.system}.greet ];
        } ''
          greet | grep -q "packaging craft"
          touch $out
        '';
        addone-runs = pkgs.runCommand "addone-runs" {
          nativeBuildInputs = [ self.packages.${pkgs.system}.addone ];
        } ''
          test "$(addone 41)" = "42"
          touch $out
        '';
      });

      devShells = forAllSystems (pkgs: {
        default = pkgs.mkShell {
          packages = [
            self.packages.${pkgs.system}.greet
            self.packages.${pkgs.system}.addone
          ];
        };
      });
    };
}
nix build .#greet
./result/bin/greet
nix build .#addone
./result/bin/addone 41
nix flake check
nix log ./result     # after a failed build: nix log .#attr

Host consumption (optional)

# on a NixOS host module
{ pkgs, inputs, ... }:
{
  environment.systemPackages = [
    inputs.self.packages.${pkgs.system}.greet
  ];
}

Or without rebuilding the host: nix shell .#greet.


Worked example — intentional phase map document

For every package, keep a one-pager:

## addone
- unpack: directory src (default)
- patch: none
- configure: skipped (no configure script / default no-op)
- build: $CC addone.c
- check: none (doCheck false)
- install: install -Dm755 → $out/bin/addone
- fixup: strip + shebang (n/a for pure ELF)

This document is gold when something fails six months later.


Lab — multi-step

Suggested workspace: ~/lab/nixos/packaging/stdenv

Step 1 — Scaffold

mkdir -p ~/lab/nixos/packaging/stdenv/{pkgs,src}
cd ~/lab/nixos/packaging/stdenv

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)

Wire the flake above. Confirm:

nix build .#greet -L
nix build .#addone -L
nix run .#greet

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 (e.g. add pkgs.pkg-config wrongly then correctly). On native Linux both may “work”; write one sentence on why native is still correct for compilers and pkg-config.

Step 5 — Phase introspection

nix build .#greet -L
nix path-info -r ./result | head
nix derivation show .#greet | head -c 2000

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

Step 6 — Drop runHook once

Remove runHook preInstall / postInstall from greet, rebuild. Note: may still succeed for trivial packages—document why hooks still matter for non-trivial trees (wrappers, setup hooks).

Step 7 — Parallel build flag

For a make-based package (hello), set enableParallelBuilding = true and rebuild with -L. Note make job messages.

Step 8 — Host or shell consumption

Wire systemPackages or use nix develop / nix shell. Prove the binary is on PATH.


Exercises

  1. Empty $out: Write a derivation that “succeeds” but leaves $out empty—does Nix allow it? What does nix path-info show? Fix by installing a marker file.
  2. sourceRoot: Unpack a nested tarball layout (or fake one) and set sourceRoot correctly.
  3. Check phase: Enable doCheck with a trivial checkPhase that greps your binary’s help text.
  4. meta.mainProgram: Break mainProgram, run nix run .#greet, fix it.
  5. Compare to nixpkgs: nix edit nixpkgs#hello (or equivalent) and list three differences from your toy expressions.

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 dontUnpack + copy, or custom unpackPhase
Name collisions / impure timestamps in C Avoid embedding timestamps; prefer -D defines from version only
rec + override surprises Prefer finalAttrs pattern
Darwin vs Linux $CC assumptions Use stdenv’s $CC, not hard-coded gcc
Forgetting meta.license Set it; habit for nixpkgs-shaped work
Parallel race in custom buildPhase Document; fix races or disable parallel for that package

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 notes
  • Phases documented for each package
  • Hooks (runHook) used in overridden phases
  • Flake checks smoke-test at least one package
  • Optional: compared personal hello packaging to pkgs.hello

Commit

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

Write three personal gotchas before continuing to fetchers and FODs.