In a recent post, Determinate Systems’ co-founder Graham Christensen argued that Nix is shaping up to be the tool for our time, citing as evidence a recent GitHub announcement about Nix flake support in Dependabot, the reinvigoration of the hardware space, big name adoption of Nix, AI tools making Nix code ever “cheaper” to write and maintain, and, most importantly for us today, a dramatic recent rise in supply chain complexity and risk.
An avalanche of recent CVEs—too many to even name at this point—make the urgency of supply chain security unmistakably clear. Here, I’d like to delve into one facet of Graham’s argument and say more about the relationship between software supply chain security and what I call Nix’s core paradigm. I’ll cover three foundational but deeply intertwined Nix concepts—derivations, sandboxing, and closures—and argue that they come together to provide Nix builds with a transparency and introspectability that’s lacking in leakier abstractions, such as building with Makefiles and shell scripts.
The transparency and introspectability that Nix provides will not free us from the painstaking work of building valuable software when the supply chain is constantly subject to threats ranging from social engineering to malicious maintainers to porous authentication practices in major platforms. But I will argue here that Nix’s core paradigm provides a foundation for practices and tools that will help us navigate this precarious moment.
Derivations
derivation function that enables you to tell Nix which builder program to use, which system type to build for (x86_64-linux and so on), and, almost always, a set of arguments to pass to the builder.
Here’s an example:
{ pkgs }:
derivation { name = "hello"; system = builtins.currentSystem; builder = "${pkgs.bash}/bin/bash"; args = [ "-c" "mkdir -p $out/share\necho 'Hello, World!' > $out/share/hello.txt" ];}This derivation uses Bash as the builder and runs a script that creates a directory named share ($out represents the output directory that everything is written to) and writes a file called hello.txt to that share directory.
At first glance, this seems not so different from building something using a shell script or a Makefile.
But a few things are quite different! First, the builder (Bash) is itself a derivation, which means that you can examine its dependency graph as well:
nix path-info --recursive --json nixpkgs#bashThe implication: even the builder tool is considered part of the dependency graph, not a hidden background assumption. The only background assumption is Nix itself essentially acts as a meta-builder.
Second, the $out variable here indicates that everything in Nix is built inside a sandbox (more on that in a minute).
The Bash script can’t write to /usr/bin or /usr/local or even $PWD, so it can’t affect any sensitive parts of your system.
It can only write to the hello package:
nix run nixpkgs#tree -- $(nix build --print-out-paths nixpkgs#hello)The output should look something like this:
/nix/store/chnjf7vj55192vmdd2kfjjdlhzj7vill-hello-2.12.3├── bin│ └── hello└── share ├── info │ └── hello.info └── man └── man1 └── hello.1.gzYou see an executable plus some ancillary info and the man pages (basically an instruction manual) as a tarball, all of it written to the Nix store at /nix/store and nowhere else on your filesystem.
The example derivation above was a bit too straightforward to be realistic, so let’s make things a bit more true to life by seeing what a derivation for a Go web server might look like:
{ pkgs }:
pkgs.buildGoModule { name = "auth-server"; src = ./.; vendorHash = "sha256-B79ZiQ9szxan9VUKH9BAcp253cq7KYwXBs81LtRZDus="; nativeBuildInputs = [ pkgs.protobuf ];}Here, the buildGoModule function is basically a fancy wrapper around the raw derivation function we used above.
The src set to ./. means that the current directory (the one with your Go code) is put in the Nix sandbox, the vendorHash ensures that the dependencies specified in go.sum are always the same when fetched (this is the only way around the sandbox’s default restriction on network access), and nativeBuildInputs is a list of packages that need to be available while the server is being built (in this case protoc).
The Go executable itself is provided by the buildGoModule function.
This derivation is more complex but the core principles are the same.
Your Go code is “invited” into the build by the src attribute.
The packages involved in the process are themselves built by Nix.
Let’s make derivations themselves a bit more concrete.
During the build process, Nix first converts derivations into an intermediary format called ATerm.
Here’s an example for the hello package in Nixpkgs:
cat $(nix eval --raw nixpkgs#hello.drvPath)That format encapsulates all of the information Nix needs to run the build, but it isn’t all that readable on its own. Much more readable is the JSON:
nix derivation show nixpkgs#hello | jqThis JSON object shows you the derivation builder, arguments, system, patches applied, and much more. This is mostly stuff that you’ll never need to know, but the key point is that the information is there, and it’s highly granular and available for every derivation in every Nix dependency graph.
The informational walls you often run into with Makefiles and Bash scripts and even some Dockerfiles have been replaced with top-to-bottom introspectability. And this introspectability is available for container images you build with Nix, for development environments, for NixOS systems that you run, and so on.
Let’s move on from build instructions to the environment in which those instructions do their work.
Sandboxing
All Nix builds are
Do you want to fetch something from the open Internet?
You can do that using functions like fetchurl, but you have to declare a hash of the contents first; if the contents change in any way, the build fails.
Does the derivation builder need to use some filesystem state?
You can do that, as we did above when we declared src = ./. in the Go derivation, but you can’t access system paths like /bin or /usr because the contents of those paths are almost certain to vary across systems, and differences of that sort are precisely what Nix is built to smooth over.
Here’s an example of a derivation that doesn’t work (runCommand is a simple derivation wrapper):
pkgs.runCommand "build-thing" {} '' mkdir -p $out/share cp $HOME/.netrc $out/share''In an ordinary script, copying $HOME/.netrc would be totally fine.
Nix, however, throws a No such file or directory error because the host’s home directory isn’t accessible to the sandbox.
There are some ways around these network and filesystems restrictions, like using the --impure flag for nix build, but they’re generally frowned upon because they go firmly against the grain of Nix’s core paradigm.
Sandboxing is vital from a security perspective, because who wants to have their build logic mucking around with system internals? And it forms a nice complement to derivations. Derivations make build logic fully introspectable while sandboxing restricts the contents of what those instructions can act upon.
Closures
Any time you build something with Nix, you have to provide explicit, introspectable build instructions and then the build itself runs in an oppressively dark cave of an environment that only sees the shards of light that you allow it to. That sounds like a great start, but what about actual software that runs somewhere and produces real value?
This is where
Build-time closures include everything necessary to build your software—the entire dependency tree that needs to be available inside the sandbox. Firefox, for example, requires GCC in its build-time closure, amongst many other things.Runtime closures include everything necessary to run your software in the desired environment. To give an example, Firefox needs GCC only at build time but it needs to have GTK available when you actually fire up the browser.
So when you run a command like nix run nixpkgs#firefox, the firefox derivation in Nixpkgs handles all of this for you.
At build time, GCC and other dependencies are made available, and then at runtime GTK and other tools are available in your Nix store.
Here’s a derivation for a piece of software that requires Node.js in its build-time closure and OpenSSL in its runtime closure:
{ pkgs }:
pkgs.stdenv.mkDerivation { name = "my-auth-server";
src = ./.;
# Build-time inputs nativeBuildInputs = [ pkgs.nodejs ];
# Runtime inputs buildInputs = [ pkgs.openssl ];
# The build script (with Node.js and npm available) buildPhase = '' mkdir -p $out/bin npm run build cp ./dist/* $out/bin '';}From a supply chain perspective, the beauty of closures is that they are, by definition, complete They provide closure! And that completeness comes with total introspectability. Want to know the full set of tools that were used to build your web server? Ask Nix. Want to know what will be running alongside your web server on your EC2 instance? Ask Nix.
To put it cheesily, closures form the “chain” in “supply chain.” They can’t ensure that nothing nefarious is lurking in your production environment but they do provide an awful lot of sunlight.
Package interdependence
The final piece of the puzzle is something that I’ll call package interdependence here. It describes the fact that the packages in package sets like Nixpkgs are fundamentally intertwined. When you update a more “basic” dependency, like Bash or a C compiler or coreutils, those updates have consequences throughout the graph, forcing rebuilds in all dependent packages.
What this means concretely is that if, say, GCC is updated inside the package set because a vulnerability is discovered, you can then rebuild everything that depends on it. You don’t have to update gcc 11 times in 11 different repos or Makefiles or scripts. So if you’re using PostgreSQL and ffmpeg, which depend on gcc, you can immediately rebuild those packages with the patched gcc in the graph.
And so with Nix you end up with a powerful “lever” that you can pull to secure dependency graphs.
We at Determinate Systems, for example, maintain a package set called Determinate Secure Packages, which is based on Nixpkgs.
When we’re notified of a CVE, we patch any relevant packages, rebuild our entire covered package set, and cache the build results in
Bringing it all together
We now have the puzzle pieces necessary to step back and provide a macro picture.
Everything you build in Nix—packages, OCI containers, entire Linux systems using NixOS, whatever—requires build instructions called
It’s always important to remember that nothing I’m describing here is a “capability” or “feature” of Nix. This is all just how Nix works, everywhere and all the time.
To be clear, Nix by itself doesn’t ✨ magically ✨ solve your supply chain problems once and for all. But I do think that it provides you more of a fighting chance than anything else. And that fighting chance actually comes from how not magical Nix is. Rather than magical, Nix is radically transparent, enabling you to pick apart every aspect of every build pipeline, and constrained, forbidding arbitrary access to networks, filesystems, and much else.
In upcoming posts, we’ll say a lot more about what we’re doing concretely in the domain of supply chain security, from the secure packages offering I mentioned above to tools like