Nix From the Ground Up

By Ross Light
Nix logo

I recently spent some time learning Nix after watching this talk by Xe. Nix is a package manager/build system for Linux and macOS. It does a number of things I really like:

  • Transparent handling of source and binary packages.
  • Includes a rich central package registry, but you can host your package descriptions or binaries anywhere.
  • Does not require root and runs alongside any Linux distribution.
  • Easy to pin or customize versions of individual packages.
  • Straightforward support for project-specific dependencies.

Nix is a cool piece of tech, but in my opinion, it’s pretty hard to learn (at least at time of writing). I think this is accidental complexity: I was able to be productive with Nix in my personal projects in a few days, but it took a fair amount of research from many different sources. I took a lot of notes, then realized I wanted to publish them to share this knowledge.

So here’s my guide! “Nix From the Ground Up” aims to help explain the concepts behind Nix with a hands-on approach.

This guide assumes you…

  • know Unix command line basics
  • know at least one programming language
  • have familiarity with at least one other package manager like Homebrew or apt

I recommend reading the sections in order, as each section builds on concepts from the previous ones.

I’ll warn you up-front: I am not a Nix expert. Please let me know if you find any part of this post confusing or incorrect, and I will try to fix it.

For posterity, I’m using Nix 2.5.1 with nixpkgs unstable at commit 0c408a087b4751c887e463e3848512c12017be25.

EDIT 2022-01-25: Added a reference to the exact expression that nix-shell -p uses.

Contents

The Language

The foundation of Nix is its programming language. Until I learned the language, most of the documentation was hard to understand because the underlying mechanisms seemed very opaque.

Fortunately, the language is pretty straightforward: its syntax is pretty similar to Lua, but it is lazily evaluated like Haskell. The nix repl program is helpful for learning the language, which is what I would use to check the basics.

The language has the basics like strings, numbers, and lists:

2 + 2             # returns 4
"foo" + "bar"     # evaluates to "foobar"
[ 2 "foo" true ]  # a list with three items.
                  # Elements are separated by spaces.

Nix’s primary data type is the set, which is like Lua’s table type or the dictionary/map/hash type in other languages. Each name/value pair inside a set is called an attribute.

{}                 # the empty set
{ x = 5; y = 7; }  # a set with two attributes. The trailing semicolon is mandatory.
{ x = 5; }.x       # The typical dot syntax gets the value of an attribute.

The Nix language exists in service of package management, so it has a few domain-specific data types. Notably, inline paths allow referencing files alongside a .nix file:

./foo.txt  # references foo.txt in the directory the REPL is running in
           # or relative to the .nix file the path is written in.
           # This will automatically be made into an absolute path.

./.        # The current directory.
           # Nix paths must include at least one slash.

# Paths can be concatenated with strings:
./foo + "/bar.txt"

Local variables can be introduced with the let ... in construct:

let x = 2; in x + 3  # evaluates to 5

Nix functions are always anonymous, but can be assigned to variables. Function arguments are separated from the function name by a space, like in Haskell.

(x: x + 2) 2  # evaluates to 4

let
  f = x: x + 2;
in
  f 3  # evaluates to 5

And also like Haskell, multiple parameters can be handled by creating higher-order functions:

let
  add = x: y: x + y;
in
  add 40 2  # evaluates to 42

But more commonly, functions take in a set. Individual attributes can be bound to names using pattern matching:

let
  addSet = { x, y }: x + y;
in
  addSet { x = 40; y = 2; }  # evaluates to 42

There’s also a pattern matching syntax for optional arguments:

let
  scale = { x, factor ? 2 }: x * factor;
in
  scale { x = 5; }  # evaluates to 10

The last fundamental part of the Nix language is derivations. Derivations are created using the built-in derivation function, but you should typically invoke a helper function from nixpkgs (which we’ll talk about in a moment) rather than calling derivation yourself. However, under the hood, all helper functions eventually call the built-in derivation function.

# This is a demonstration, not a real example.
derivation {
  system = "x86_64-linux";
  name = "foo-1.2.0";
  builder = ./builder.sh;
  # ...
}

A derivation, upon evaluation, creates an immutable .drv file in the Nix store (typically located at /nix/store) named by the hash of the derivation’s inputs. A separate step, called realisation, ensures that the outputs of the derivation’s builder program are available as an immutable directory in the Nix store, either by running the builder program or downloading the results of a previous run from a cache. Since Nix takes precautions to make the builder invocation hermetic (details in the derivations section of the manual), these outputs can be shared safely between machines of the same OS and architecture. Cool!

It’s important to keep in mind that the Nix language is lazily evaluated. This means that even if you write let mySet = { x = 2 + 2; };, the expression 2 + 2 will not be evaluated until its value is needed. This means that sets can be huge without having to compute all the values.

This covers the basics of the language for the purposes of this guide, but there are a few more syntactic elements and many more built-in functions. For further reference, see the Nix expressions chapter of the Nix manual.

Enter nixpkgs

Now that we have a basic grasp of the Nix language, let’s examine the “standard library”: nixpkgs. nixpkgs defines a set that contains a variety of helper functions as well as an entire repository of software. When you installed Nix, it came with a copy of nixpkgs. You can make it available in your nix repl by running:

nixpkgs = import <nixpkgs> {}

You can find out what version of nixpkgs you have installed with the expression:

nixpkgs.lib.trivial.version
# "22.05pre340162.0c408a087b4" for the author

Nix used to have a channel model, but seems to have moved to a build from HEAD model recently. This means that new installations use “unstable”, so the commit is usually more useful information:

nixpkgs.lib.trivial.revisionWithDefault ""
# "0c408a087b4751c887e463e3848512c12017be25" for the author

Each software “package” is represented as an attribute in the nixpkgs set. For example, a derivation for the Go programming language toolchain is available as nixpkgs.go. You can find nixpkgs attributes using search.nixos.org, or you can examine pkgs/top-level/all-packages.nix in the nixpkgs repository itself. I find I usually have the nixpkgs source open in an editor and the nixpkgs manual open in a browser when I’m working on Nix stuff.

Installing Software

So how do we connect all these concepts to installing software? nix-env is a simple command to install derivations in your user environment, which acts similar to other package managers:

nix-env --install --attr nixpkgs.go
# commonly abbreviated to:
nix-env -iA nixpkgs.go

This will place the go tool into your PATH:

which go
# $HOME/.nix-profile/bin/go

This is a symlink to the go binary in the Nix store:

readlink `which go`
# /nix/store/5zvhj5hvy9mpgr7h8bjw3hj4jfnfd9zh-go-1.16.10/bin/go

Since this binary is deliberately not placed in a common path like /usr/local/bin, different users on a system can use different Go versions. However, if two users are using the same version, they’ll use the same binary.

You can update the software in your Nix user environment by running:

nix-channel --update &&
nix-env --upgrade

This is roughly equivalent to Debian’s apt-get update && apt-get upgrade. If you have a multi-user Nix installation, you may need to replace the first command with sudo -i nix-channel --update. Packages will be matched via the derivation name, not the attribute name.

Because the Nix store is immutable, if something goes wrong during the update, you can roll back!

nix-env --rollback

nix-env is great for getting the latest version of packages, but where Nix really shines is providing a concise, declarative language for describing and sharing project-specific environments. So let’s take a look at the next tool: nix-shell.

Shells from Expressions

nix-shell starts a bash shell with a specified set of derivations present. nix-shell --packages will grab attributes from nixpkgs:

$ nix-shell --packages go

[nix-shell:~]$ go version
go version go1.16.10 linux/amd64

You can use exit or Control-D (EOF) to exit the interactive shell. Using --run lets you run a single bash statement instead of an interactive session:

nix-shell --packages go --run 'go version'
# Output:
# go version go1.16.10 linux/amd64

This is just the beginning! nix-shell --expr gives you the full power of the Nix language. The previous invocation is roughly equivalent to:

nix-shell \
  --expr 'with import <nixpkgs> {}; mkShell { packages = [go]; }' \
  --run 'go version'

The nixpkgs.mkShell function evaluates to a derivation that just depends on other derivations. This is necessary because nix-shell starts a bash shell with the dependencies of the derivation resulting from the expression — not the derivation itself. You can look in the Nix source for the exact expression used.

Using an expression gives you the ability to pin to a specific commit of nixpkgs using the fetchTarball built-in function:

nix-shell \
  --expr 'let p = import (
    fetchTarball "https://github.com/NixOS/nixpkgs/archive/0c408a087b4751c887e463e3848512c12017be25.tar.gz"
    ) {}; in p.mkShell { packages = [p.go]; }' \
  --run 'go version'

As you can imagine, this would be unwieldy to type often, but does give you a shell that gives you the exact same version of Go regardless of machine. While you could create shell scripts or aliases that wrap nix-shell --expr, it’s much more convenient to store the expression in a file. Let’s go ahead and create a file called shell.nix with the following contents:

# Define a function that takes an optional parameter, pkgs.
# It defaults to a pinned nixpkgs version.
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/0c408a087b4751c887e463e3848512c12017be25.tar.gz") {}
}:

pkgs.mkShell {
  packages = [ pkgs.go ];
}

You can run nix-shell with a file argument and it evaluates the file as a Nix expression. The new trick we’re using here is that if the expression evaluates to a function, like in the example above, nix-shell will call the function with an empty set, then use the result.

nix-shell shell.nix --run 'go version'

Because shell.nix is the convention, we can omit it:

nix-shell --run 'go version'

Using nix-shell, we can maintain a shell.nix file in our project and ensure that we’re always using the same versions of our tools across machines and team members. You can reuse these expressions to provide consistent environments for continuous integration, building Docker images, or deploying cloud VM instances.

Overrides and Overlays

Up to this point, we’ve been using the version of the packages provided in nixpkgs. While nixpkgs will sometimes include multiple versions (e.g. go and go_1_17), there may come a point at which you want to pin to a specific version. The way you do this depends on the package, but you can usually use the overrideAttrs function to create a modified derivation. For example, here’s an expression I’ve written to pin SQLite to 3.36.0:

pkgs.sqlite.overrideAttrs (oldAttrs: {
  version = "3.36.0";
  src = pkgs.fetchurl {
    url = "https://sqlite.org/2021/sqlite-autoconf-3360000.tar.gz";
    sha256 = "vZDD65a+6ZYga4O+cGXJzhmu84w/T7Uwc62g0LabvOM=";
  };
});

In some cases, overriding will not work. This usually happens with more complex helpers like nixpkgs.buildGoModule. In these cases, you can copy the source .nix file from nixpkgs and make modifications yourself. You can use the nixpkgs.callPackage function to import files written in nixpkgs style.

The techniques outlined so far in this section create an isolated package: if another package depends on sqlite, it will use the nixpkgs version, not our pinned 3.36.0 version. If you want everything in nixpkgs that depends on sqlite to also use 3.36.0, you can use an overlay. An overlay is a function that returns a set of overrides for the nixpkgs set, passed in as an argument to the imported nixpkgs function. For example:

let
  # Overlays take two parameters:
  # self: The final nixpkgs set.
  # super: The nixpkgs that the overlay is wrapping.
  sqliteOverlay = self: super: {
    super.sqlite.overrideAttrs (oldAttrs: {
      version = "3.36.0";
      src = self.fetchurl {
        url = "https://sqlite.org/2021/sqlite-autoconf-3360000.tar.gz";
        sha256 = "vZDD65a+6ZYga4O+cGXJzhmu84w/T7Uwc62g0LabvOM=";
      };
    });
  };

  pkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/0c408a087b4751c887e463e3848512c12017be25.tar.gz") {
    overlays = [ sqliteOverlay ];
  };
in
  # ...

This blog post goes into more detail about overlays and has some helpful diagrams.

Sharing Built Derivations

Once you start using complex Nix configurations across machines, especially ones with overlays, Nix will likely have to build from source rather than fetch from the NixOS cache. This is unnecessarily slow: because Nix builds are hermetic, you can reuse results from previous builds, even across machines! Nix can access shared stores over HTTP(S), SSH, or S3-compatible storage. The official docs point out there is a hosted service, Cachix, but for my hobby projects, I found the pricing prohibitively expensive. Luckily, setting up a Google Cloud Storage bucket is pretty easy.

If you’re interested in the gory details, see this S3 blog post and the GCS follow-up post for more information. Here’s a quick summary of what I did:

To start, I created a GCS bucket and a Nix signing key:

gsutil mb -l us "gs://${BUCKET_NAME?}" &&
nix-store --generate-binary-cache-key "${BUCKET_NAME?}-1" cache-private.txt cache-public.txt

GCS supports the S3 protocol using HMAC keys for authentication. This required me to create an ~/.aws/credentials file with my user account HMAC key.

To get the closure of the store paths needed to build a shell:

binstores=( $(nix-store -qR $(nix-build --no-out-link shell.nix -A inputDerivation)) )

For the following steps, I needed to enable some experimental commands in the Nix CLI. You can do this with:

mkdir -p ~/.config/nix &&
echo 'experimental-features = nix-command' >>| ~/.config/nix/nix.conf

I signed the closure and uploaded with:

sudo -i nix store sign --key-file cache-private.txt "${binstores[@]}" &&
nix copy --to "s3://${BUCKET_NAME?}?endpoint=https://storage.googleapis.com" "${binstores[@]}"

If you accidentally run nix copy before signing, you can sign the store paths after the fact by passing an undocumented --store option to nix store sign (see NixOS/nix#4221 for details):

nix store sign \
  --store "s3://${BUCKET_NAME?}?endpoint=https://storage.googleapis.com" \
  --key-file cache-private.txt \
  "${binstores[@]}"

Finally, I configured my machines to fetch from the GCS bucket (nix.conf reference):

sudo mkdir -p /etc/nix &&
echo "extra-substituters = s3://${BUCKET_NAME?}?endpoint=https://storage.googleapis.com" |
  sudo tee -a /etc/nix/nix.conf &&
echo "extra-trusted-public-keys = $(cat cache-public.txt)" |
  sudo tee -a /etc/nix/nix.conf

Conclusion

That’s all I’ve got for now. I hope this guide helped you understand how you can use Nix to manage software dependencies. If you’re interested to learn more, head over to the NixOS learning page for more resources. As I said at the beginning, please reach out if you have any feedback on this guide. Thanks for reading!