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-darwinname: the package namebuilder: 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 invocationNIX_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:
nix-instantiate: compile the.nixfile into an intermediate.drvfilenix-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:
stdenv.mkDerivation, a wrapper aroundderivationthat sets sensible defaultssetup.sh, a generalized 1600-line build script that consumes those defaults
Together they provide a set of conveniences:
- compute
LDFLAGSfor each library dependency - compute
CFLAGSfor each header search path - provide core tools like
make, the C compiler, andbash - 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:
.nixfiles get compiled to.drvartifacts, which are essentially declarative descriptions of inputs, output paths, and environment variables. From here on, the Nix language is no longer involved.- Each
.drvinvokes a build script, passing the declared environment variables. - When using
stdenv, part of that environment is a shared bash scriptsetup.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.



