Demystifying nix derivations
2025-06-14
When I started using nix and NixOS, I quickly needed to write my own simple packages ("derivations"), and had to read the definitions in nixpkgs. For a while I was mostly using a trial and error approach without fully understanding how the derivations actually work. What finally made it click for me was making a few derivations directly using the nix derivation built-in without any of the nixpkgs abstractions.
I will show how to do that on the usual GNU Hello package example. This is a bit similar to the Working Derivation post of the "Nix Pills", but I'll start from mkDerivation instead of building up to it.
mkDerivation magic
If you look at a simple example of building GNU Hello (copied below), it seems pretty straightforward, but you'll have a hard time explaining why it works, it involves rather "magic" behaviour:
- it is not a derivation, but a function returning a derivation, where do the arguments come from?
- there are no explicit build instructions, how does it know how to build the source code?
- what compiler is used?
{ lib, stdenv, fetchurl }:
stdenv.mkDerivation rec {
pname = "hello";
version = "2.12";
src = fetchurl {
url = "mirror://gnu/${pname}/${pname}-${version}.tar.gz";
sha256 = "1ayhp9v4m4rdhjmnl2bq3cibrbqqkgjbl3s7yk2nhlh8vj3ay16g";
};
}
When it works, it's great, you can ignore those pesky details, but when it fails, you have to dig into the documentation or implementation, and you might wish you had a Dockerfile instead.
Bare builtins.derivation
The mkDerivation function is essentially a wrapper around the derivation built-in function. Trying to use it directly is what made the concept click for me.
What is a derivation exactly? It's build instructions to create at least one1 output, an output being a file or file hierarchy at a path provided to the builder through an environment variable.
Minimal derivation
The smallest derivation I could come up with is the following:
builtins.derivation {
name = "minimal-derivation";
builder = "${./builder-hello-world}";
system = "x86_64-linux";
}
./builder-hello-world is a static binary I built against musl (of course using a nix derivation!). It does the equivalent of the bash script:
mkdir $out
echo "Hello world!" > $out/hello.txt
Building this derivation calls the builder binary with the out environment variable set to a path in the nix store. The builder must create a file or directory at that path. The path is computed by taking a hash of the build instructions (see this blog post for the nitty gritty details!).
Conceptually the build does something akin to:
# Evaluation time
builder=/nix/store/$(cat ./builder-hello-world | hash)-builder-hello-world
name="minimal-derivation"
system=x86_64-linux
out=/nix/store/$(echo "$name" "$builder" "$system" | hash)-$name
# Build time
cp ./builder-hello-world $builder
tmpdir="$(mktemp -d)"
cd "$tmpdir"
env --ignore-environment out="$out" "$builder"
rm -rf "$tmpdir"
The builder binary is copied to the nix store, but it is not a derivation. It is added in a content-addressed manner through path literals.
Building it with nix gives you a hello.txt file in the nix store:
$ nix-build minimal-derivation.nix
/nix/store/dx639h9bqsmjfdxwx85cxjl3lsw2708m-minimal-derivation
$ cat /nix/store/dx639h9bqsmjfdxwx85cxjl3lsw2708m-minimal-derivation/hello.txt
Hello world!
Getting dependencies
To get from this minimal derivation to building the hello package, we need a bunch of packages and tools like gcc, make, etc. To avoid going into a bootstrapping rabbit hole, let's rely on nixpkgs for that like in the mkDerivation example.
When you see a function like this in nixpkgs:
{ lib, stdenv, fetchurl }:
(... derivation ...)
It relies on a dependency injection mechanism called callPackage, which essentially looks up each argument in the nixpkgs package set.
You can call it explicitly, in nixpkgs it is done in all-packages.nix among other places. It takes two arguments, the function to do dependency injection on, and arguments you want to pass explicitly; e.g.:
pkgs.callPackage (
{ lib, stdenv, fetchurl }:
(... derivation ...)
) {
stdenv = pkgs.gcc15Stdenv; # Use gcc15 instead of the default
}
which is equivalent to calling the function like this:
({ lib, stdenv, fetchurl }:
(... derivation ...)
) {
lib = pkgs.lib;
stdenv = pkgs.gcc15Stdenv; # Use gcc15 instead of the default
fetchurl = pkgs.fetchurl;
}
Building hello
Building hello with a bare derivation and dependencies from nixpkgs can for example be done by using bash as a builder and passing a script as argument:
{ lib, bash, gcc, gnumake, fetchurl, coreutils, gnused, gnugrep, gawk, gnutar, xz, gzip }:
builtins.derivation rec {
pname = "hello";
version = "2.12";
src = fetchurl {
url = "mirror://gnu/${pname}/${pname}-${version}.tar.gz";
sha256 = "1ayhp9v4m4rdhjmnl2bq3cibrbqqkgjbl3s7yk2nhlh8vj3ay16g";
};
name = "${pname}-${version}";
builder = "${bash}/bin/sh";
PATH = "${bash}/bin:${gcc}/bin:${gnumake}/bin:${coreutils}/bin:${gnused}/bin:${gnugrep}/bin:${gawk}/bin:${gnutar}/bin:${xz}/bin:${gzip}/bin";
args = [
"-c"
''
tar --strip-components=1 -xzf $src
./configure --prefix=$out
make
make install
''
];
system = "x86_64-linux";
}
A bunch of tools need to be added to PATH, and the build steps are explicit: unpacking the source, ./configure, make, make install.
Lifting the veil
In practice you likely won't use builtins.derivation because it is too low level.
But once you know how it works under the hood, you can peek into what mkDerivation actually executes.
Coming back to the hello example, you can inspect the derivation using the nix repl:
nix-repl> pkgs = (import <nixpkgs> {})
nix-repl> pkgs.hello
«derivation /nix/store/vmn4z8cvaxdxa5i56lbl82gqzddh4jik-hello-2.12.1.drv»
nix-repl> pkgs.hello.builder
"/nix/store/xy4jjgw87sbgwylm5kn047d9gkbhsr9x-bash-5.2p37/bin/bash"
nix-repl> pkgs.hello.args
[
"-e"
/nix/store/p893dkrzm5rxvhnqh092prgi1a7dzmcy-source/pkgs/stdenv/generic/source-stdenv.sh
/nix/store/p893dkrzm5rxvhnqh092prgi1a7dzmcy-source/pkgs/stdenv/generic/default-builder.sh
]
Knowing that it's calling builder with args, we can now see it's using bash as the builder, running these source-stdenv.sh and default-builder.sh scripts, and you can look at them directly in the nix store, or find them in the nixpkgs repository.
Digging a bit further, you can start figuring out how it actually works: it starts at the genericBuild function which runs different phases:
- the source is unpacked in the unpackPhase
- it automatically runs
./configureif it's present in the configurePhase - runs
makein the buildPhase - and
make installin the installPhase
among a bunch of other things (other phases, hooks, ...), everything being configurable through environment variables which are passed through from the derivation attributes.
I still find it hard to figure out what mkDerivation exactly does, but at least I can get closer by reading a bunch of bash code!
Pragmatic runCommand & co helper functions
While I would not recommend using builtins.derivation in practice, a nice alternative when you don't want to deal with the mkDerivation logic is to use the (funnily named) trivial build helpers like runCommand. For example:
{ lib, runCommandCC, fetchurl }:
runCommandCC "hello" {
src = fetchurl {
url = "mirror://gnu/hello/hello-2.12.tar.gz";
sha256 = "1ayhp9v4m4rdhjmnl2bq3cibrbqqkgjbl3s7yk2nhlh8vj3ay16g";
};
} ''
tar --strip-components=1 -xzf $src
./configure --prefix=$out
make
make install
''
Nix is simple (?)
At its core nix is simple: run a builder and you get a file or folder out! But that's also its drawback, it needs (possibly complex) abstractions to actually make it useful and scalable, which is what nixpkgs achieves.
I hope seeing how a derivation is build without the abstractions helps you better understand nix like it did for me!
Here you can find the code of the examples I used in this post.
To be sure I tried to build a derivation without any output and was greeted with a helpful error: 'builtins.head' called on an empty list.