stdenv and build phases
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.
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 flagsMultiple 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):
- Move docs/man if multi-out helpers apply
- Strip binaries (unless
dontStrip)
patchShebangson scripts under$out
- Compress man pages
- 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 # LinuxWorked 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 .#attrHost 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/stdenvCreate 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
pkgs/greet.nix— shell script package
pkgs/addone.nix— single-file C
- Optional: package
helloyourself withfetchurl(compare topkgs.hello)
Wire the flake above. Confirm:
nix build .#greet -L
nix build .#addone -L
nix run .#greetStep 3 — Break phases on purpose
In addone.nix, set temporarily:
installPhase = "false"; # fail installBuild, read the log, note which phase failed:
nix build .#addone --print-build-logs || true
# restore installPhase with runHook wrappersStep 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 2000Document: 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
- Empty
$out: Write a derivation that “succeeds” but leaves$outempty—does Nix allow it? What doesnix path-infoshow? Fix by installing a marker file.
sourceRoot: Unpack a nested tarball layout (or fake one) and setsourceRootcorrectly.
- Check phase: Enable
doCheckwith a trivialcheckPhasethat greps your binary’s help text.
- meta.mainProgram: Break
mainProgram, runnix run .#greet, fix it.
- 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
greetandaddonebuild withnix build
- At least one intentional phase failure diagnosed from logs
nativeBuildInputsvsbuildInputsexplained in notes
- Phases documented for each package
- Hooks (
runHook) used in overridden phases
- Flake
checkssmoke-test at least one package
- Optional: compared personal
hellopackaging topkgs.hello
Commit
git add .
git commit -m "packaging: stdenv phases — greet + addone packages"Write three personal gotchas before continuing to fetchers and FODs.