Contents

Using Nix for Package Management

As per their website, Nix is a tool that takes a unique approach to package management and system configuration. It creates reproducible, declarative and reliable systems. In this post I will cover how I have been using Nix to configure development and build environments.

Introducing Nix

Nix is a purely functional package manager. This means that it treats packages like values in purely functional programming languages such as Haskell — they are built by functions that don’t have side-effects, and they never change after they have been built.

Looking at their website we can see there are multiple parts to the project. The first is Nix which refers to the package manager as well as the functional expression language used to configure the packages and environments. The second is nixpkgs which is a collection of thousands of packages for the Nix package manager. Lastly, there is also NixOs which is an operating system based on the Nix package manager.

Admittedly, I still have a lot to learn about Nix. The project and its feature set is quite large. For the setup in this post we will be looking at some of the base features, but it is worth knowing that Nix also offers the following:

  • Multiple package versions
  • Multi-user support
  • Atomic upgrades and rollbacks
  • Language integration (Go, Python, Rust, etc…)
  • Image building (Docker, Snap, etc…)
  • Framework integration (Android, IOS, Qt, etc..)
Note
With the below setup, all developers as well as the build environment will be using the exact same version of the packages required to build or run the application. This should completely avoid inconsistencies between development environments between developers as well as the test and build pipelines.

Requirements

We are going to need a few things before we get started. The instructions below will be for Linux (WSL to be specific), but I will include links to the documentation in case you are running something else.

Nix

To install Nix we only need to run a single command:

1
sh <(curl -L https://nixos.org/nix/install) --no-daemon
Install
Check the documentation for the right command to run for your operating system.

Niv

Niv is a plugin for Nix that makes it easier to manage packages in a Nix project, track specific branches for packages, and also import packages from custom URLs and GitHub.

I have not mentioned this yet, but you can also use Nix to install system packages. We will be using it now to install Niv:

1
nix-env -iA nixpkgs.niv

Assuming that all the steps during your Nix installation were successful, the Nix binary directory should already be added to your path and you can now use Niv.

direnv

direnv acts as an extension to your shell and allows you load/unload environment variables and virtual environments depending on your current directory.

The easiest way to install direnv is via Homebrew:

1
brew install direnv

Once it is installed we also need to configure a hook for our shell. In my case I had to add the following to my ~/.zshrc file:

1
eval "$(direnv hook zsh)"

Initialising Our Project

We can now finally initialise our first development environment. First let us create a new directory for our project:

1
mkdir nix-is-awesome && cd nix-is-awesome

We will be using Niv to track a specific branch of nixpkgs. In our case we want to use the unstable branch to use the latest versions of the packages. Luckily, Niv includes a handy command to initialise the project and create your sources file:

1
niv init -b nixpkgs-unstable

You should see output similar to this:

/images/niv-output.png
niv init

Now that our sources file exists we can create a shell.nix file that makes use of it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
cat << EOF > shell.nix
let
  sources = import ./nix/sources.nix { };
  pkgs = import sources.nixpkgs { };
in
pkgs.mkShell {
  buildInputs = [
  ];
}
EOF

The code above will load our sources file, create a reference to nixpkgs, and lastly start a nix-shell. The final step is to ensure that our nix-shell is loaded when we enter the directory. The command below will configure direnv to do exactly that:

1
echo "use nix" > .envrc

As soon as you run the above command you should see an error similar to this:

/images/direnv-error.png
direnv error

This is a security feature of direnv. To allow the script to run, execute the following command:

1
direnv allow

direnv should now initialise the nix-shell and you should see output similar to the following:

/images/nix-shell-init.png
nix-shell

nix-create

To simplify all the above steps I have created a simple bash script:

Run the following commands to add nix-create as a global command:

1
2
3
curl https://gist.githubusercontent.com/geevcookie/96f8b937ebffb7ddfc9eb3467bb0e7c4/raw >> nix-create
chmod +x nix-create
sudo mv nix-create /usr/local/bin/nix-create

Finding Packages

To find a package to install on Nix is fairly easy, thanks to their handy search page.

Search
When searching, ensure that you have the correct channel selected. If you are following this guide, you need to switch the search to use the unstable channel as it is not selected by default.

Nix is also capable of installing packages for specific languages. Search for a few of the following to see what I mean by this:

  • nodePackages
  • php81Packages
  • go-swagger

As of the time of writing some of the Nix features are in beta and are hidden behind feature flags. One of these commands is nix search. To enable the command run the following:

1
2
mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf

You should now be able to search for packages via the CLI like this:

1
nix search nixpkgs go_1_17

Adding Some Packages

Let’s start by adding Go version 1.17 to our project. To do this add the following line to the buildInputs in your shell.nix:

1
pkgs.go_1_17

Your shell.nix should now look like this:

1
2
3
4
5
6
7
8
9
let
  sources = import ./nix/sources.nix { };
  pkgs = import sources.nixpkgs { };
in
pkgs.mkShell {
  buildInputs = [
    pkgs.go_1_17
  ];
}

As soon as the file is saved the nix-shell should rebuild and the go command should be available. To confirm that you are using the version installed via Nix, run which go and you should see output similar to this:

/images/which-go.png
which go

Adding Custom Binaries

Sometimes you might need to add an additional custom binary to your environment. With the above setup this is easily done.

Note
This might not be the best way to do this, but as I find a better ways I will be updating this post.

In our example, we will be adding the extremely useful tool jq via the binary releases published on GitHub. The first step is to create a new file in ./packages/jq.nix:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{ pkgs ? import <nixpkgs> {} }:

pkgs.stdenv.mkDerivation {
  name = "jq";

  src = if pkgs.system == "x86_64-linux"
        then pkgs.fetchurl {
          url = "https://github.com/stedolan/jq/releases/download/jq-1.6/jq-linux64";
          sha256 = "af986793a515d500ab2d35f8d2aecd656e764504b789b66d7e1a0b727a124c44";
        }
        else pkgs.fetchurl {
          url = "https://github.com/stedolan/jq/releases/download/jq-1.6/jq-osx-amd64";
          sha256 = "5c0a0a3ea600f302ee458b30317425dd9632d1ad8882259fcaf4e9b868b2b1ef";
        };

  phases = ["installPhase"];
  installPhase = ''
    mkdir -p $out/bin
    cp $src $out/bin/jq
    chmod +x $out/bin/jq
  '';
}

The above code creates a reference to the 1.6 release of jq for both 64bit Linux and OSX. Additionally, we define an install step so that Nix knows how to handle the binary file downloaded. The last step is to update our shell.nix file to reference our new derivation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
let  
  sources = import ./nix/sources.nix { };  
  pkgs = import sources.nixpkgs { };  
  jq = pkgs.callPackage ./packages/jq.nix {};  
in  
pkgs.mkShell {  
  buildInputs = [
    pkgs.go_1_17
    jq  
  ];  
}

We should now have the exact version of jq available wherever we run our nix-shell.

GitHub Actions

We can also use our nix-shell in our GitHub Actions. This will ensure a consistent environment during development, testing, building, and releasing. The workflow below will execute a single command while in the nix-shell context:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
name: "Nix"
on:
  pull_request:
  push:
jobs:
  tests:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - uses: cachix/install-nix-action@v15
      with:
        nix_path: nixpkgs=channel:nixos-unstable
    - run: nix-shell shell.nix --run "which go"

If you want to run a more complex script you can also use nix-shell as a script interpreter. Below is an example of an executable script that you can execute during your GitHub actions workflow:

1
2
3
4
#!/usr/bin/env nix-shell
#!nix-shell shell.nix -i bash

which go

Conclusion

With Nix you can easily define the exact packages you require for your environment. With the setup described above you can easily enforce which packages to use during the entire lifecycle of the application. This helps you achieve the following:

  • Faster developer onboarding as the setup above will take care of all the dependencies
  • Avoid situations where applications on CI/CD boxes are no longer compatible with your code
  • No more “it works on my machine”
  • Easier integration with custom binaries required in your environment

As I mentioned previously, this is a very simple introduction to what Nix can do. For a more in-depth guide check out Nix Pills published on the Nix website.