The Derivation Build Process

Updated

September 12, 2026

The Derivation Build Process

stdenv.mkDerivation is how Unix software becomes a store path. The boring default is: keep the standard phases, override only the ones you must, and let stdenv set $CC, $out, RPATH, and the sandbox.

Raw derivation { } (fundamentals) is the primitive. This chapter is what you write for real C.

Mental model

Phase Default
unpackPhase Unpack $src
patchPhase patch -p1 each of patches
configurePhase ./configure --prefix=$out if that script exists
buildPhase make
checkPhase make check if doCheck = true
installPhase make install
fixupPhase patchelf RPATH, strip, gzip man pages

Skip a phase with dontConfigure = true; (or dontBuild, dontInstall — rarely). Override a phase with buildPhase = ''…'';. Always mkdir -p $out/bin before cp if you write installPhase by hand.

$CC is the stdenv compiler (gcc or clang), already wrapped with hardening flags. Do not call gcc by name.

nativeBuildInputs run on the build machine (make, pkg-config, $CC). buildInputs are linked into the output (zlib, openssl). Mixing them is why a cross build links host openssl. strictDeps = true; makes that mix an eval/build error — turn it on for new C packages.

enableParallelBuilding = true; (and enableParallelChecking) is make -j. Default is often already on in 26.05 stdenv; if a Makefile is racy, set it false and say why.

Custom phases must still fire hooks:

buildPhase = ''
  runHook preBuild
  $CC -O2 -o desk-status-c main.c
  runHook postBuild
'';

Skipping runHook drops preBuild from nativeBuildInputs (code generators, autoreconfHook).

$src → unpack → patch → configure → build → check → install → fixup → $out

Worked examples

Case 1: One C file, no autotools

Save as c_tool.nix:

# c_tool.nix
{ lib, stdenv }:

stdenv.mkDerivation {
  pname = "desk-status-c";
  version = "1.0";
  src = ./.;
  dontConfigure = true;
  buildPhase = ''
    cat > main.c << 'EOF'
    #include <stdio.h>
    int main(void) {
      puts("Desk C worker active");
      return 0;
    }
    EOF
    $CC -O2 -o desk-status-c main.c
  '';
  installPhase = ''
    mkdir -p $out/bin
    cp desk-status-c $out/bin/
  '';
  meta = {
    description = "Compiled C desk status check";
    license = lib.licenses.mit;
  };
}

Save as default.nix:

# default.nix
{ pkgs ? import <nixpkgs> { } }:

pkgs.callPackage ./c_tool.nix { }
nix-build default.nix
./result/bin/desk-status-c

Output:

Desk C worker active

Case 2: doCheck

# in c_tool.nix
doCheck = true;
checkPhase = ''
  ./desk-status-c
'';

checkPhase runs before installPhase, in the build directory. The binary is still ./desk-status-c, not $out/bin.

nix-build default.nix

A failing check fails the derivation. Good.

Case 3: $src as a real tree

Put main.c in git and drop the cat from buildPhase:

stdenv.mkDerivation {
  pname = "desk-status-c";
  version = "1.0";
  src = ./src;
  dontConfigure = true;
  buildPhase = ''
    $CC -O2 -o desk-status-c main.c
  '';
  installPhase = ''
    mkdir -p $out/bin
    cp desk-status-c $out/bin/
  '';
}

Filter src (source-filtering chapter) so .git is not an input.

Case 4: Inspect the wrapped compiler

nix-build default.nix
nix-store -q --references result

You should see glibc (or musl), not gcc. gcc is a build-time input; fixupPhase did not copy it into the runtime closure.

readelf -d result/bin/desk-status-c | grep PATH

RUNPATH points at store libc. That is stdenv, not you.

Case 5: outputs (preview)

outputs = [ "out" "dev" ];

Multiple outputs split runtime (out) from headers (dev). Skip until a closure-size measurement says headers are the problem. One $out is the desk default.

The trap

The trap is installPhase without mkdir -p $out/bin. cp: cannot create … No such file. The build log is long; the last line is the one that matters.

The other trap is gcc main.c instead of $CC. Cross and clang stdenvs then break. $CC is the contract.

The boring rule

  • Standard phases. dontConfigure when there is no ./configure.
  • $CC, not gcc. $out, not /usr. nativeBuildInputs vs buildInputs. strictDeps on new C.
  • Custom phases call runHook preBuild / postInstall.
  • mkdir -p before cp in a custom installPhase.
  • doCheck = true when tests exist and are hermetic.
  • Language builders (buildGoModule, buildRustPackage) wrap this. Use them when they exist.

Try this

  1. Add Case 2; nix-build; then checkPhase = "false"; and confirm failure.
  2. Replace $CC with gcc; cross later will hurt — put $CC back.
  3. nix path-info -Shr result vs nix-store -q --deriver result references — runtime vs build.
  4. Forget mkdir; save the error; restore.