Nix for Development

Read Time: 9 minutes

For awhile I’ve admired Nix from afar, but I’ve recently taken the opportunity to dive in and get a feel for it myself. This is mostly an aggregation of initial impressions and ideas of how I can leverage Nix in my development workflow.

To start off, this is a view into the Nix package manager, not NixOS. The distro is on my list to evaluate, but that is a task for another day. Additionally, this isn’t meant to be a Nix tutorial, the Nix project has some good starting information. My goal here is just focusing on the development experience. For those not familiar with Nix, it is a package manager focused on reproducibility. For me, that is the appeal. I’m always looking for ways to make my process at home and work better; providing a more consistent environment is part of that. In addition, Nix should open opportunities for additional flexibility and safety while dealing with different tooling versions.

To that point, tooling versions can be particular pain point when dealing with different projects and teams in an organization. Inevitably, there are projects needing that particular version of Node or Python (to just pick on two language runtimes). Their respective communities have come up with solutions to this problem, tools like nvm, nodeenv, virtualenv, and pyenv, to just name a few. Nix, among other things, solves this problem using a overarching singular solution. It is a consistent answer across different types of languages and projects. Honestly, it is not that hard to manage different languages with their own set of tools. But the ability to manage tooling dependencies the exact same way at a higher level is very appealing.

Since Nix is a package manager, not just a language runtime manager, that means I can control more than Python or Node versions. I can use it add sorts of application dependencies for a project. These may be required or recommended developer tooling for the project. As example, lsof or netcat installation can be part of the project development package. Outside of project-specific usage, you can use Nix as your general system package manager (technically that’s the underlying intent anyway). But for those who want to just dip their toes, you don’t need to go all-in for OS package management. It’s just an option to consider. I will say, if you’re not going to go all-in with Nix, just make sure you clearly delineate your boundaries. The last thing you want to do is add the mental load of “hrm, how did I install that app?”.

It is time to dig a bit deeper, and that starts at understanding the different aspects of the Nix.

  • Nix, the package manager
  • Nix, the shell
  • The Nix configuration files

An example can do wonders for understanding how everything pieces together. For this example project I’m going to be use .Net Core (v3) and NodeJs (v14). First make sure Nix is installed; which adds the package manager, nix shell, plus several other tools. Once complete, it is time to setup the project-specific tooling configuration. This is handled by the shell.nix file (or default.nix). In the project directory, I’ll create the file shell.nix. Once created, I add the development dependencies. This includes the .NET Core sdk as well as the NodeJs runtime; available options here. It doesn’t have to end there, I can also run commands and/or set environment variables for the project’s Nix shell.

shell.nix:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{ pkgs ? import <nixpkgs> {} }:

pkgs.mkShell {
buildInputs = [
pkgs.dotnet-sdk_3
pkgs.nodejs-14_x
];

shellHook = ''
echo starting...
'';

MY_VAR = "foobar";
}

Now my project directory looks like this:

1
2
3
4
5
6
7
8
~/projects/myproject(dev)$ tree -L 1
.
├── main.fsx
├── main.js
├── node_modules
├── package.json
├── package-lock.json
└── shell.nix

Once this is created, just run nix-shell. This handles project setup as well as development dependencies. The below example shows the manifestation of the defined development dependencies. Currently I have dotnet installed globally, and node not installed at all. So inside my nix-shell my dotnet version changes, while node and my environment variable are made available.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
~/projects$ cd myproject
~/projects/myproject(dev)$ echo $MY_VAR

~/projects/myproject(dev)$ dotnet --version
3.1.403
~/projects/myproject(dev)$ node --version
bash: node: command not found
~/projects/myproject(dev)$ nix-shell
starting...

[nix-shell:~/projects/myproject]$ echo $MY_VAR
foobar

[nix-shell:~/projects/myproject]$ dotnet --version
3.1.402

[nix-shell:~/projects/myproject]$ node --version
v14.9.0

[nix-shell:~/projects/myproject]$ which dotnet
/nix/store/wdy002bjakj6x2a6mdqajg11j23b3v2v-dotnet-sdk-3.1.402/bin/dotnet

[nix-shell:~/projects/myproject]$ which node
/nix/store/0f4ar6v0qvsb6cnyz1k2avhg9frsjr8s-nodejs-14.9.0/bin/node

[nix-shell:~/projects/myproject]$ dotnet fsi main.fsx
Hello, MY_VAR = foobar

[nix-shell:~/projects/myproject]$ node main.js
Hello, MY_VAR = foobar

[nix-shell:~/projects/myproject]$ exit
exit

Great! I now have development dependencies set for my project. Add the newly created nix.shell to source control, and teammates can now develop in a similar environment (assuming they also have Nix of course). So, this is a bit of a lie. Others can get close, but the question is, how do I ensure actual reproducibility so we all can truly see the same version of the tools? To understand the situation better, I need to mention channels. Channels are a Nix construct that defines a consistent sets of packages that can be installed. As an example unstable, 20.09, 20.03, 19.09 are some of the channels available at the time of this writing. Everyone could have their default channel set to a different channel. Each channel having it’s own version of applications, for example dotnet-sdk_3 is version 3.1.101 in the channel 20.03 and 3.1.102 in 20.09. So a user’s system can be consistent within itself, but how do you get consistency across a team? You pin a shell.nix to a specific git commit. This guarantees everyone works off the same set of packages. For reference you can find the proper commit for a channel on the status page. To do this, I modify the first line of my shell.nix to pin it to a specific commit of the unstable channel.

1
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/cfed29bfcb28259376713005d176a6f82951014a.tar.gz") {} }:

Now we have everyone on the same page. I believe this setup can go a long way to making a better development experience. But there is more. This works, but some may find entering the nix-specific shell every time a hassle. This is where direnv enters the picture. Once this is installed, I can automatically enter a nix shell when I cd into the project directory. Aside: There are other solutions for this, direnv is just what I settled on. It works as a nice addition to make the whole experience seamless. Once direnv is installed and configured, there is a little project-specific setup. First, in the project directory create a .envrc file. Second, authorize direnv to activate on the project directory. This second step is important. Since direnv won’t just run code because an .envrc file is there, you have to tell it which dirs it is supposed to run against.

1
2
3
4
5
6
7
cd myproject

# Create an environment file
echo "use nix" > .envrc

# Enable direnv for this dir
direnv allow

With that in place, now when I enter the directory, Nix auto configures and I’m good to go. Fwiw, direnv can be used for more than just instantiating Nix. In fact, there is some overlap in nix-shell and direnv functionality. Things like environment variables can be configured in either shell.nix or .envrc. It’s worth figuring out where the right balance is for you. I prefer to keep static and development-specific configuration across environments in the shell.nix. For things that can, or should, vary across environments I prefer an environment-specific file, like .envrc. All these pieces put together make for a nice development experience. My initial experience has been good and I look forward to putting this setup through it’s paces.