A practice I’ve been following lately is creating isolated development environments for my programming projects, so that their dependencies are managed separately from other packages on the system. Some of the benefits of this approach:
- each project can have different versions of packages.
- packages that are only needed for a single project don’t have to be installed globally.
- experimenting with new languages and frameworks becomes trivial and doesn’t result in the system accumulating cruft over time.
Some of these benefits may seem similar to what can be achieved by using containers, and Docker. However, while containers are great for deployment, from my experience Nix, the focus of this post, generally provides a better local development experience. I’m also specifically talking about package/dependency isolation here, rather than process isolation, which is a problem that containers solve rather than Nix.
Background on Nix
I’ve dabbled in Nix in various forms on and off for a few years now. For those who aren’t familiar with it, it’s a package manager for Unix-like operating systems that aims to solve various problems around package management.
There’s also the Nix language, which is used by the package manager; it’s what package build specifications (which Nix calls Derivations) are written in. Last but not least, there’s the Linux distribution NixOS, which applies the same principles to the configuration of an entire Linux system.
It’s difficult to do Nix justice with my brief explanation here (it’s a complex tool solving complex problems) and I still have a lot of learning to do myself about its intricacies. I’d recommend giving a read of some of the original PhD thesis to learn more about it; my understanding became a bit clearer from reading about the problems Nix was trying to solve and some of the resultant design decisions.
I mentioned Docker/containers earlier. One of the key differences between Docker and Nix is that Nix guarantees reproducable builds, which is more difficult to achieve with containers, due to the nature of how images tend to be built. Nix can also be used to build containers from Nix expressions!
Nix Flakes
My approach to per-project isolated development environments uses Nix flakes, which are an experimental Nix feature (in practice flakes are very widely used and I haven’t personally experienced any issues so far). The Nix Flake proposal RFC provides a good explanation for why flakes came about. Namely: improved reproducability, standardisation and composability for Nix expressions.
Example
Here’s a minimal flake.nix
example that builds a dev shell with the go
compiler, the gopls
language server and the delve
debugger.
{
description = "Some Go project...";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
nixpkgs,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system: let
pkgs = nixpkgs.legacyPackages.${system};
nativeBuildInputs = with pkgs; [
go
gopls
delve
];
buildInputs = with pkgs; [];
lib = pkgs.lib;
in {
devShells.default = pkgs.mkShell {inherit nativeBuildInputs buildInputs; };
}
);
}
Combined with direnv, I can simply cd
to the directory containing this flake.nix
file, and go
, gopls
and delve
will be available in my PATH
. The Emacs direnv
package integrates this into my editor too.
Templates
Flake templates allow initialising new projects with pre-defined nix flake expressions and files. Running nix flake show templates
displays a list of templates that can be used, and nix flake init -t template#<template name>
will initialise a project with that template. The default templates come from https://github.com/NixOS/templates but you can also create your own repository of flake templates. Here’s a great explanation on how to do that and an introduction to flake templates in general.
Alternative approaches
There are two projects I’m aware of that build upon the concept of Nix-based development environments, which many may find useful.
devenv
I tried this tool out briefly. It seemed to offer a higher-level of abstraction than the sort of devShell flake seen in this post. For example, instead of specifying each package needed for your language stack explicitly, you can just write languages.go.enable = true
. I thought this was neat, but I didn’t want to introduce yet another tool into my workflow. I also wasn’t happy with it adding even more boilerplate (than already present from using flakes + direnv) to each of my repos; e.g. a .devenv
directory, devenv.yaml
file, etc. I think once I’ve hit a limit with what I can achieve with my current way of doing things, I’ll probably take another look at devenv.
devbox
Seems to be similar to devenv, but with more of the Nix details abstracted away from the user. It still depends upon Nix, but you don’t have to write any Nix language expressions, just JSON configs. It could be a good choice for developers that want to harness Nix without delving into the language right away, which can be a bit esoteric especially if you aren’t particularly comfortable with pure functional languages.
Further reading
Other posts I’ve found that cover this topic: