Nix Builds, From the Bottom Up

Nix documentation tends to start from the top: the Nix language, the package repository, the abstractions. If you come from a traditional C build background, that can feel backwards. You know Makefiles, compiler flags, and environment variables. Nix builds ultimately run a build script, but it can be hard to see that through the layers.

One way to demystify it is to take a real C program and compile it while bypassing most of Nix's usual machinery. This is the story of building paperjam, a C program that wasn't in the Nix repository, using very little of the standard `stdenv` helper system.

What Actually Happens When You Build Something

Everything in Nix ultimately comes down to a derivation. It's a function in the Nix language, and its whole purpose is to describe a build. The function takes three required arguments:

  • system: the target platform, e.g. x86_64-darwin
  • name: the package name
  • builder: the program (usually a bash script) that runs the build

Any other key passed to derivation becomes an environment variable available to the build script. A derivation also claims its inputs: if you reference another derivation, Nix builds that dependency first and replaces the reference with the dependency's output path in /nix/store.

A Minimal Build Attempt

The initial plan was deliberately naive. The build script would simply unpack the source tarball and run make install. The derive expression would fetch the source via fetchurl and pass the tarball's path to the builder as the SOURCE environment variable.

That fails immediately, and fixing the errors one by one is the quickest way to learn how the build system is wired together.

Problem 1: No PATH

The first error is tar: command not found. Nix builds run without a PATH environment variable at all, so no tools are visible. Setting the PATH to include make, gzip, and tar is the obvious fix.

Problem 2: No Compiler

Next comes the compiler missing. The build script uses `clang++` to compile the source. The fix here is to add a compiler to the PATH and set the CXX environment variable so the Makefile invokes the right one.

Problem 3: Header Files Are Isolated

Once the compiler runs, it can't find any headers. This is core to Nix's isolation: your system header files are simply not visible inside the build environment.

The compiler in the store isn't a bare binary. It's a wrapper script that translates extra environment variables into flags. Inspecting that script reveals the two key variables:

  • NIX_CFLAGS_COMPILE_aarch64_apple_darwin: arguments prepended to the compiler invocation
  • NIX_LDFLAGS_aarch64_apple_darwin: arguments prepended to the linker invocation

Setting those to point at the libpaper and qpdf libraries fixes the header problem.

Problem 4: C++ ABI

Linking then fails on missing symbols. Adding -L ${pkgs.libcxxabi}/lib to the linker flags picks up the required C++ ABI library.

Problem 5: iconv

The next failure is on an iconv symbol. Adding the library path alone doesn't help. The key was to compare against a previously working build of the same program by adding debug printing to the compiler wrapper. The working build's linker flags included an explicit -liconv. Adding that flag fixes it.

The original Makefile doesn't include -liconv because it assumes Linux with glibc, where iconv functions are part of the C library. On macOS libc, that's not the case, so the link step needs to be explicit.

Problems 6-8: Missing Tools

The linker next needs codesign_allocate, a macOS code-signing tool. The binary lives in the cctools package; adding ${pkgs.darwin.cctools}/bin to the PATH provides it.

The final missing binaries are routine: a2x from asciidoc, and install (plus date) from coreutils. Both get added to the PATH.

Problem 9: Wrong Install Location

The make install phase fails because the Makefile defaults to PREFIX=/usr/local. Works. Passing the desired output path via make install PREFIX=$out the build completes.

What the Final Configuration Looks Like

The whole exercise adds only a few environment variables to the derivation and one flag to `make install`. The core architecture stays the same as the initial stub.

That simplicity is worth reflecting on. A tiny derivation can call a single function to build a working package. And behind the scenes, running nix-build is really two operations:

  1. nix-instantiate: compile the .nix file into an intermediate .drv file
  2. nix-store --realize: run the build according to the .drv

The .drv file itself is inspectable with nix show-derivation and contains a readable list: environment variables, the build script path, input store paths.

The Convenience Layer: stdenv

Real package builds rarely involve writing environment variables by hand. The stdenv wrapper exists to generate them automatically, and it has two pieces:

  1. stdenv.mkDerivation, a wrapper around derivation that sets sensible defaults
  2. setup.sh, a generalized 1600-line build script that consumes those defaults

Together they provide a set of conveniences:

  • compute LDFLAGS for each library dependency
  • compute CFLAGS for each header search path
  • provide core tools like make, the C compiler, and bash
  • detect the target system
  • let each package inject shell code at specific build phases

Looking at the compiled derivation for a standard package like jq shows that stdenv's outputs can embed shell script fragments. Some environment variables contain multi-line bash that the main build script later `eval`uates -- for instance a postInstallCheck snippet verifying the binary was installed correctly.

Recap: What Nix Builds Actually Do

This exercise brings the whole flow into focus:

  1. .nix files get compiled to .drv artifacts, which are essentially declarative descriptions of inputs, output paths, and environment variables. From here on, the Nix language is no longer involved.
  2. Each .drv invokes a build script, passing the declared environment variables.
  3. When using stdenv, part of that environment is a shared bash script setup.sh, which provides a standard lifecycle: unpack, patch, configure, compile, install, check.

Cleaning Up

Debugging this build consumed roughly 3GB of disk with intermediate downloads and outputs. A single nix-collect-garbage reclaims all of it instantly, which is one of Nix's best features.