stdenv and mkDerivation

the standard build environment and how most packages are built.

builtins.derivation gives you nothing. no compiler, no mkdir, no cp. you provide everything yourself.

stdenv.mkDerivation sets up a full build environment.

builtins.derivation
name = "hello";
system = "x86_64-linux";
builder = "${bash}/bin/bash";
args = [ "-c" "gcc -o $out/bin/hello $src/hello.c" ];
+ set up PATH manually
+ unpack source manually
+ install into $out manually
+ fix RPATHs manually
stdenv.mkDerivation
pname = "hello";
version = "1.0";
src = ./src;
PATH, unpack, configure, build, install, fixup: handled

what stdenv is

stdenv is a derivation itself. it lives in the store like any other package. its output contains:

  • a C/C++ compiler (gcc on linux, clang on Darwin)
  • core unix tools (coreutils, findutils, sed, grep, awk, tar)
  • make, patch, and binutils
  • a setup script (setup.sh) that defines the build phases
what stdenv provides
bashbuilderruns the build script
coreutilsbasicscp, mv, mkdir, cat, echo, ...
findutilsbasicsfind, xargs
diffutilsbasicsdiff, cmp
gnusedbasicssed
gnugrepbasicsgrep
gawkbasicsawk
gnutararchiveunpack source tarballs
gzip / bzip2 / xzarchivedecompress archives
gnumakebuildmake
gnupatchbuildpatch
gcc / clangcompilerC/C++ compiler (gcc on linux, clang on Darwin)
binutilscompilerld, ar, strip, objdump
patchelffixuprewrite ELF RPATHs (linux only)

when you write stdenv.mkDerivation { ... }, nix adds stdenv to inputDrvs. the builder script sources setup.sh from stdenv's output, which sets up PATH from all your declared dependencies and runs through the build phases.

how mkDerivation works

mkDerivation takes your attribute set (or a function, for the finalAttrs self-referencing pattern) and transforms it before calling builtins.derivation:

stdenv.mkDerivation {
  pname = "curl";
  version = "8.18.0";
  src = fetchurl { ... };
  buildInputs = [ openssl zlib ];
  nativeBuildInputs = [ pkg-config ];
  configureFlags = [ "--with-openssl" ];
}

what happens:

  1. name is computed from pname and version: "curl-8.18.0"
  2. builder is set to stdenv.shell (bash)
  3. args is set to [ "-e" source-stdenv.sh defaultBuilder ]. source-stdenv.sh sources $stdenv/setup, then defaultBuilder calls genericBuild
  4. buildInputs paths are joined into a space-separated string in the env var. the setup script splits this and adds their bin/ to PATH, their lib/ to linker paths, their include/ to compiler include paths
  5. nativeBuildInputs works the same way but for tools that run on the build machine. when doCheck = true, checkInputs get merged into nativeBuildInputs automatically
  6. every other attribute becomes an environment variable. strings pass through. paths get copied to the store. lists are joined with spaces. true becomes "1", false and null become ""

two attributes are special: meta (license, description, maintainers) and passthru (extra attributes like tests and updateScript) are stripped before calling builtins.derivation. they do not become env vars, do not appear in the .drv, and do not affect the output hash. changing a package's description does not trigger a rebuild.

mkDerivation also sets NIX_HARDENING_ENABLE with a default set of compiler hardening flags: stack protector, fortify source, position-independent code, RELRO, and more. these are on by default for all packages. hardeningDisable = [ "all" ] turns them off.

the resulting .drv still comes from builtins.derivation. mkDerivation is convenience, not a separate build model.

buildInputs vs nativeBuildInputs

for native builds (not cross-compiling), the distinction barely matters. both end up on PATH.

for cross-compilation, it is the difference between working and broken:

  • nativeBuildInputs: tools that run during the build. compilers, code generators, pkg-config. these run on the build machine
  • buildInputs: libraries the output links against. these are for the target architecture
{
  nativeBuildInputs = [ pkg-config cmake ]; # run at build time
  buildInputs = [ openssl zlib ]; # linked into the output
}

if you put a build tool in buildInputs during cross-compilation, nix provides the target-architecture binary. it cannot execute on your build machine. the build fails.

the setup script

the args are two scripts. source-stdenv.sh runs first:

source $stdenv/setup
source $1  # the actual builder

and default-builder.sh is just:

genericBuild

source $stdenv/setup loads the setup script from stdenv's store path. this script:

  1. sources setup-hooks from all declared dependencies (packages can inject behavior into the build)
  2. constructs PATH, PKG_CONFIG_PATH, CMAKE_PREFIX_PATH, etc. from dependency outputs
  3. defines the phase functions: unpackPhase, configurePhase, buildPhase, installPhase, fixupPhase
  4. defines genericBuild, which calls each phase in order

genericBuild is the entry point. it runs the phases sequentially. each phase has a default implementation and can be overridden.

overriding

any attribute in mkDerivation can be overridden after the fact:

hello.overrideAttrs (old: {
  configureFlags = (old.configureFlags or []) ++ [ "--disable-nls" ];
})

overrideAttrs takes a function from the old attributes to a set of overrides. the override is merged (shallow //) with the original. the result is a new derivation with a new hash.

the original derivation is untouched. laziness means the original attributes are thunks that never get forced if the override replaces them.

build phases are what genericBuild actually runs. each phase is a shell function with hooks you can tap into.