MP

Nixifying the Blog

Over the past year or so, I have hopped on the nix train. I am steadily working towards a full NixOS setup, but in the meantime the nix package manager has proved to be the best way I've ever found to manage cross-platform dependencies, whether for my own machines via home-manager or to manage isolated and complete development environments for a variety of projects. At SpecTrust, we have been using nix to manage dependencies for our linux and MacOS engineers for some time now, and it's been a pretty amazing success. I'm sure I'll write something about it in our engineering blog at some point, at which point I'll link to that from here.

Table of Contents

Why Nix

Nix is an optionally declarative, deterministic, cross-platform package manager. A package installed via nix for a given system type (e.g. linux-amd64) is guaranteed to be byte-for-byte compatible with the same package installed on another system of the same type. Nix also supports checking in pinned versions of package sources, ensuring that all installs over time get the same versions of specified packages.

What's more, nix package installs are isolated from the rest of the system: a nix package installed and used in a development environment doesn't interfere with any other versions of that package installed on the system, nor does it interfere with any other versions of that package installed by nix. Outside of the context of the development environment, it's like the installed package doesn't even exist.

Essentially, nix brings the declarative nature, isolation, and cross-platform support of something like npm or pip with virtual environments to system packages also. This is a huge boon for shared development environments, where you can be certain that everyone using nix is using exactly the same version of any required dependencies, including compilers/runtimes (rustc, cargo, node, python, etc.), classic shell utilities (sed, awk, make, etc.), modern shell utilities (fd, jq, ripgrep, etc.), infrastructure tooling (terraform, kubectl, etc.), and background services (postgres, vault, etc.). It's hard to overstate how nice it is to not need to worry about whether you can use modern features of bash, make, or awk, since you know that even engineers running MacOS with its decrepit old BSD forks will have the same versions of those tools installed that you do. It's also hard to overstate how much nicer a simple nix invocation for installing all project dependencies is compared to a long list of projects and utilities to go find in your system package manager or brew or whatever.

With nix, I can be working on one project that uses Rust 1.53 and switch to another project that uses 1.56 seamlessly, without those installs touching my system rust. With direnv support, which we'll discuss below, this happens automagically in both my shell and my editor, meaning I never have to worry about switching virtual environments or toolchains or worrying about whether I've installed all the dependencies for a given project.

Okay, but it can't all be roses, right? Of couse not, it never is. If you'd like to be talked out of trying nix, see the Why not Nix section at the bottom.

Blogging with Nix

For this blog, I used the upcoming flakes feature, which is a first-party way of pinning dependencies and ensuring hermetic builds. If you'd like to use flakes, you'll need to activate them once you've installed nix. For other projects I have used niv, which is a third-party solution that does the same. Both work well, but flakes are the future, and they're really quite nice. I'll include a section later on how you'd accomplish the same basic thing with niv.

The Flake and Nothing But the Flake

If we could assume we're in the future where everyone is using nix with flakes, the flake.nix file would be the only one we need. The shell.nix file that lives alongside is only there for backwards compatibility. So, let's talk first about the flake.

The general form of a flake is:

{
  description = "...";
  inputs = {...},
  outputs = {...}: {...}
}

The description is just that, a string describing the project. We'll talk about each of inputs and outputs in turn.

Note that nix operations using flakes will only see files that have been staged in git!

inputs

inputs is an attrset (what other languages call a hashmap or a dictionary or an associative array or an object or...). It describes what sources you'll be deriving your packages from. For projects that don't need backwards compatibility, only need to work on one system type, and don't need anything outside of the standard nixpkgs package set, inputs can be as simple as:

inputs = {
  nixpkgs.url = "nixpkgs/nixos-unstable";
}

This will pin your flake (via the flake.lock, which is produced whenever you run anything) to the current unstable version of nixpkgs, which contains all of the definitions for all of the packages you can install with nix. You can of course pin it to any other branch you'd like, such as the most recent stable branch, although unstable is fairly stable in practice, so for non critical applications it's probably fine.

Usually, you'll want your flake to apply to more than just one system type (e.g. linux-x86-64). Specifying that support in the outputs is a bit of a chore, so you'll also often want to bring in the flake-utils library:

inputs = {
 # Provides some nice helpers for multiple system compatibility
 flake-utils.url = "github:numtide/flake-utils";
};

We'll talk more about what it does when we get to the output.

For backwards compatibility, there's the flake-compat library, which we will also pull in:

inputs = {
 # Proivdes legacy compatibility for nix-shell
 flake-compat = { url = "github:edolstra/flake-compat"; flake = false; };
};

Finally, we use the wonderful rust overlay (an overlay just adds extra packages to nixpkgs or overrides attributes of packages already there) from oxalica, found here. We include the overlay the same way we've included everything else, so our inputs when all is said and done look like:

inputs = {
 nixpkgs.url = "nixpkgs/nixos-unstable";
 # Proivdes legacy compatibility for nix-shell
 flake-compat = { url = "github:edolstra/flake-compat"; flake = false; };
 # Provides some nice helpers for multiple system compatibility
 flake-utils.url = "github:numtide/flake-utils";
 # Provides rust and friends
 rust-overlay.url = "github:oxalica/rust-overlay";
};

One thing to remember here is that dotted properties in the nix language are shorthand for nested attrsets! So nixpkgs.url = "nixpkgs/nixos-unstable" is equivalent to nixpkgs = { url = "nixpkgs/nixos-unstable"; }.

With our inputs defined, it's time to move on to our outputs!

outputs

The outputs section is a function that returns an attrset. Certain special properties in the attrset are used by default by the nix commandline tools, but the attrset can contain any properties, and nix tools can be pointed to those properties manually if needed.

The general form of a function is arg: body, and outputs look like:

outputs = { self }: { }

This is a function that takes an attrset containing at least a self property. self is a special property referring to the flake currently being evaluated.

Any other properties will be determined by your inputs: each input will be passed into the outputs as a property, so nixpkgs will be passed as nixpkgs, flake-compat as flake-compat, and so on.

There are two ways to access these inputs in the body of the outputs function. The first is to assign a name to the entire input attrset and then access that, so you might see something like:

outputs = inputs@{ self, ... }: { pkgs = import inputs.nixpkgs {}; }

The ... means that any attributes can be passed in, and inputs@ assigns the entire attrset to the variable inputs.

The other way is to unpack the inputs directly:

outputs = { self, nixpkgs }: { pkgs = import nixpkgs {}; }

As far as I know, there's no real difference between the two, and which one you use is just personal preference.

The body of outputs must be an expression that evaluates to an attrset. The attrset may contain any properties, but certain special properties have special meanings, which you can see here.

For setting up a development environment, the one we care about is devShell.<system>, which is used by the nix develop command to enter an isolated development environment. The value of devShell for a supported system will generally be the result of calling the mkShell function from nixpkgs with a set of buildInputs, which are the packages you'd like to have available in your development environment. For the contents of mkShell, you can search for documentation on shell.nix, which is the older way of doing this. It uses the same mkShell function, so anything you find there will apply here too. The <system> part defines the build shell for a supported system. If I wanted to support x86 linux and x86 Mac, for example, I would want to do something like (this is the manual method, which is a pain):

outputs = { self, nixpkgs }:
    # the let..in syntax allows you to define variables in the `let` block
    # that will be available in the `in` block.
    let 
        # call the `nixpkgs` function with the appropriate `system` to get 
        # the set of packages for that system
        
        # macOS packages
        darwinPkgs = import nixpkgs { system = "x86_64-darwin" };
        # linux packages
        linuxPkgs = import nixpkgs { system = "x86_64-linux" };
    in
    {
        devShell.x86_64-darwin = darwinPkgs.mkShell {
            buildInputs = [ darwinPkgs.bashInteractive darwinPkgs.fd darwinPkgs.gnumake ]
        };
        devShell.x86_64-linux = darwinPkgs.mkShell {
            buildInputs = [ linuxPkgs.bashInteractive linuxPkgs.fd linuxPkgs.gnumake ]
        };
    };

Of course this is pretty repetitive, and we've got a whole programming language at our disposal, so we can do better. Using the flake-utils library, which we specified as part of our inputs, we can automatically define properties for all systems that nix supports:

# This leads to exactly the same output as above, plus output for any other
# systems that nix supports
outputs = { self, nixpkgs, flake-utils }:
    # eachDefaultSystem is a function. It is called with a function that takes
    # one argument, system, and returns an attrset. eachDefaultSystem calls that
    # function with each supported system, updates the attrsets to add the
    # system qualifiers, and then merges the attrsets. so if I return an attrset 
    # { foo = "foo"; }, it will turn that into 
    # { foo.x86_64-linux = "foo"; foo.x86_64-darwin = "foo"; ... }
    flake-utils.lib.eachDefaultSystem 
    # here we define a function that takes system as an argument and returns
    # an attrset
    (system: 
        # `inherit system` is the same as `system = system`
        let pkgs = import nixpkgs { inherit system; };
        in
        # "unpacks" the pkgs attrset into the parent namespace, so we can say
        # `fd` instead of `pkgs.fd`, and so on.
        with pkgs;
        {
            devShell = mkShell {
                buildInputs = [ bashInteractive fd gnumake ];
            };
        }
    );

Once this is in place, you're done! You can run nix develop and you will be dropped into a shell with bash, fd, and gnumake, whose versions will all be determined by the pinned version of nixpkgs in flake.lock, which gets created automatically based on the inputs.

The only difference between that and the real flake that the blog uses is that the latter includes a few extra packages and applies the rust overlay. The blog's invocation of eachDefaultSystem looks like this:

    flake-utils.lib.eachDefaultSystem
      (system:
        let pkgs = import nixpkgs {
              inherit system;
              # This is just the `overlay` property on the `rust-overlay` import.
              # `overlay` is one of the standard special outpus of flakes, so we're
              # accessing that property of the rust-overlay flake!
              overlays = [ rust-overlay.overlay ];
            };
        in
        with pkgs;
        {
          devShell = mkShell {
            # Packages required for development.
            buildInputs = [
              bashInteractive
              fd
              gnumake
              # This actually reads our rust toolchain file to determine which
              # version of rust, components, and targets to install.
              (rust-bin.fromRustupToolchainFile ./rust-toolchain.toml)
              watchexec
            ];
          };
        });

Backwards Compatibility

Of course, nix flakes are an upcoming feature, and still labeled as experimental. Somebody who just rolled in off the street with regular old nix installed won't be able to run your fancy flake. Luckily, someone has written a shim for the classic shell.nix file that will evaluate your flake and provide exactly what you need for a classic nix-shell invocation, which is the old way to get an isolated local development environment.

The first thing you need to do is include flake-compat in your inputs and include it as an explicitly named argument in your outputs, e.g.:

  inputs = {
   nixpkgs.url = "nixpkgs/nixos-unstable";
   # Proivdes legacy compatibility for nix-shell
   flake-compat = { url = "github:edolstra/flake-compat"; flake = false; };
  };
  outputs = { self, nixpkgs, flake-compat }: {  }

Then the contents of shell.nix should be (you can grab a more recent version of flake-compat and get its sha256 if desired, but the one below works):

# Legacy compat for folks not on nix with flakes.
#
# flake-compat reads the flake and provides shellNix (for nix-shell) and defaultNix
# (for nix-build). We only need shell here, since we're only using nix for the
# dev environment atm.
(import (
  fetchTarball {
    url = "https://github.com/edolstra/flake-compat/archive/99f1c2157fba4bfe6211a321fd0ee43199025dbf.tar.gz";
    sha256 = "0x2jn3vrawwv9xp15674wjz9pixwjyj3j771izayl962zziivbx2"; }
) {
  src =  ./.;
}).shellNix

With this in place, you can run nix-shell and get into the same development environment that you would with nix develop!

No Flakes

If you don't want to use flakes, niv works just as well. You can install niv for your user with nix-env -iA nixpkgs.niv. You would then follow the instructions on their repo to init your sources and pin them (essentially just run niv init), and then you shell.nix would look something like:

# This file returns the evaluation of `mkShell`, which is the same thing we
# set to the `devShell` propety in the flake output above. The difference
# is that instead of our inputs being pinned by a flake, they are pinned in
# the `nix/sources.nix` file that niv generates.

let
    sources = import ./nix/sources.nix;
    # this is assuming you used the default `nixpkgs` name for nixpkgs. We can
    # call this function with no arguments, since we don't have any overlays.
    pkgs = import sources.nixpkgs {};
in
# "unpack" the pkgs attrset into the parent namespace, so we can use like
# `mkShell` instead of `pkgs.mkShell`
with pkgs;
    mkShell {
        buildInputs = [ bashInteractive fd gnumake ];
    }

That's all you need. Wow! Isn't this much simpler than flakes? Why would anyone use flakes???

Well, niv hides quite a bit of complexity from you in the automatically generated sources.nix file, so the flake is arguably simpler overall. In addition, flakes provide a standard way to share nix packages other than checking every single thing into nixpkgs, which is pretty nice. Flakes also play nicely with all of the nix tooling. It also seems to be the general direction the community is moving, so it is good to know regardless. Finally, flakes go the extra mile to ensure that your build is hermetic and fully encapsulated by the flake and its lockfile. You cannot use any inputs not defined in the flake, and you cannot use impure operations in your evaluations. While niv does a good job at pinning sources, it does not provide that extra guarantee around the reproducibility of your build.

Direnv Support

direnv is a really neat program that automatically evaluates an .envrc file in a directory whenever you enter that directory or any of its children.

To get it working, install direnv, ensuring that you've done the step adding support for your shell. It is also really nice to add a direnv plugin to your editor so that your editor environment will also evaluate the .envrc. There are quality direnv plugins available for emacs, vim, jetbrains editors, and vscode, as well as others.

The .envrc file for the blog looks like this:

# This file is loaded whenever someone `cd`s into the project directory or when
# an editor plugin does the same.

# Ensure that nix-shell packages and environment variables are set in the environment
if nix flake --help > /dev/null; then
    use flake
elif command -v nix-env > /dev/null; then
    # Use the `use nix` command from nix-direnv if available.
    # nix-direnv caches the shell definition so it doesn't need to
    # be calculated every time, significantly speeding up execution
    # of the .ennvrc file. This assumes that nix-direnv has been
    # installed via `nix-env` (via `make setup`). If you're using
    # NixOS or have installed nix-direnv another way, feel free
    # to add in checks for config files that might live elsewhere.
    if [[ -e ~/.nix-profile/share/nix-direnv/direnvrc ]]; then
        source ~/.nix-profile/share/nix-direnv/direnvrc
    fi
    # fall back to using direnv's builtin nix support
    # to prevent bootstrapping problems.
    use nix
fi

Update (2022-07-20): previously we defined our own use_flake() in the .envrc file, but this is builtin to direnv now, so we can just run use flake.

When this file is evaluated, if the user has nix plus flakes installed, it will evaluate the flake and set up the environment accordingly, meaning you don't even need to run nix develop to use all your isolated dependencies. If the user does not have nix with flakes, but does have nix, it will use direnv's bulitin use nix command to do the same thing using shell.nix.

Finally, if the user does not have flakes and has nix-direnv installed, we are sure to source it. nix-direnv caches the direnv evaluation so that it is only re-evaluated if shell.nix or .envrc changes. This can be nice because evaluation can get slow when your environment has a ton of packages. This isn't needed for flakes, because flake evaluations are automatically cached.

Why not Nix

There are two main complaints most people have about nix, which I agree with to different degrees.

The first is that the nix expression language is weird. It's a dynamically typed, lazily evaluated, functional programming language. Just that would already be pretty weird for most people, but it also contains some odd syntactic choices. This one isn't a huge problem, though, because while it is confusing at first (and the nix community is awful at commenting their code to explain what's going on to newcomers), the language is relatively simple, with a small surface area and minimal syntactic constructs. It takes a bit to get used to, but once you're used it the language ceases to be a barrier.

The second and more serious complaint is that the documentation is terrible. There's a lot of it, but it can be really hard to find simple documentation for simple tasks you might want to accomplish when you're first getting started with nix. There are some blogs that attempt to fill the void, but there's no getting around the fact that there are a ton of libraries and idioms in use in nixpkgs and almost none of them are documented. This means that you often need to dive in and read code if you want to understand how to configure a package you're installing. Once you know the language this isn't too awful (I highly recommend checking out the nixpkgs repo locally so you can grep through it and read things in the comfortable context of your editor), but it's a hell of a high hill at first. It also doesn't help that the nix community is in the midst of a major shift from classical nix environments to flakes, which are essentially declarative files that specify all your inputs and pin them in a lockfile. I like flakes, but it sucks as a beginner when some examples and documentation are flake-oriented and some aren't, because the two contexts are relatively different.

Essentially, the complaints boil down to nix having a mighty steep learning curve. The technology is amazing, and once it's in place it's solid as a rock, but getting it there is a hassle. You'll be much better off if you've got a "nix person" you're able to ask questions of. The forums are pretty good, and I hear that the folks on Discord are also helpful. Don't hesitate to reach out!

Summary

Despite the poor documentation and the learning curve, nix is one of the best tools I've encountered. It is far and away the easiest way to prevent drift between the systems of different engineers working on a shared codebase that I know of, it's got pretty much every package under the sun, and it goes a long way towards making it easy to share a codebase across Linux and Mac operating systems. I have also moved almost all of my home management (i.e. installing the packages I use on all computers, setting up services, etc.) to nix via home-manager, and it's a really great fit for that, too. I anticipate continuing on in my nix journey for the foreseeable future.

Created: 2021-11-25, Updated: 2022-07-20

Tags: blog, nix