A while ago I became aware of Nix, NixOS and people who actually use it. I was intrigued by the proposition: configure your whole system declaratively so you can avoid configuration drift and combat issues with reproducibility.

I tried it out and found that Nix is not straightforward to learn. Also, the documentation is gruesome. I decided that knowing Nix and applying it might be nice but I just don’t want to learn it right now.

However, there are tools which build on Nix to deliver something I really want: reproducible developer environments. I probably don’t need to explain why one might want that but I’ll give a quick overview of the benefits:

  • Automated installation of dependencies, runtimes, tools
  • Control over versions of used tools
  • Declaration of environment variables
  • Declaration of shell hooks
  • Easy definition of specific shell commands
  • Integration with direnv
  • Access to the extensive Nix packages repo

Also, Nix has a nice advantage over devcontainers, namely that it’s much easier to integrate your dev environment with an IDE that gets to use what’s inside. Any tool that works this way has Nix as a dependency. Since it’s, in principle, a package manager, you can install it on a variety of systems, like my own Fedora install. I prefer to use the Determinate Systems installer:

  curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install

This sets everything up and also comes with the possibility to uninstall everything again by using the appropriate option of the installer located in /nix/nix-installer after installation. This also sets up support for the new nix command syntax and flakes. If you don’t know what that is, don’t worry, it just means you don’t have to mess with config files yourself.

There are two tools that I tried and used for dev environments, devbox and devenv.

devbox

devbox is written in Go and is configured via JSON. It’s a relatively simple tool that’s a good fit, if you want something straightforward without ever having to write a single line of Nix. You install it via a one-liner:

  curl -fsSL https://get.jetpack.io/devbox | bash

or, alternatively, you can just download the binary and place it somewhere appropriate.

To start a new dev environment you type

devbox init

which creates a new devbox.json file in your current directory. If you’re like me and want integration with direnv so everything’s loaded up when you enter the directory, you can then do

devbox generate direnv

This will create an appropriate .envrc file and load it.

A devbox.json file looks something like this:

  {
  "packages": [
    "go",
  ],
  "env": {
    "FOO": "Bar",
  },
  "shell": {
    "init_hook": [
      "go version"
    ],
    "scripts": {
      "hello": [
        "echo \"Hello World!\""
      ]
    }
  }
}

Note that trailing commas are supported. Thank God. This includes your packages, environment variables, shell hooks, shell scripts and more (if you want it to). There’s a lot more you can do.

devbox also comes with a few handy commands. To add new packages to the environment you can just do

  devbox add $PACKAGE_NAME[@PACKAGE_VERSION]

The version is optional, if omitted the latest version will be selected. If you don’t know the name of the package or which versions are available, you can search for them with

  devbox search $PACKAGE_NAME

This version control is a gem because targeting a specific pacakge version with Nix can be quite tedious because it usually requires declaring a specific nixpkgs version as input but devbox kindly does this for you.

You can update the packages with

  devbox update

and a lot more. The developers also provide template files for projects containing common languages or tools which can be found here.

The only reason why I stopped using devbox myself is that the provided template doesn’t really play nice with Rust.

devenv

devenv is a project written in Nix itself and provides an abstraction for the end user such that the configuration file is a very straightforward Nix file that still provides a lot of possibilities for customization, if you desire (and know Nix).

Note that there are several ways to install devenv since it’s built with Nix. I chose the option utilizing flakes without making use of the declarative features.

To get started you need to install Cachix:

  nix profile install nixpkgs#cachix
  cachix use devenv

and then install devenv:

  nix profile install --accept-flake-config tarball+https://install.devenv.sh/latest

That’s it. To initialize a dev environment you then do

  devenv init

This creates several files, a devenv.nix which is your primary config file, a devenv.lock which is the lockfile pinning your dependencies, a devenv.yaml which contains (mostly) the used inputs and an .envrc for direnv integration.

A devenv.nix file looks something like this:

  { pkgs, ... }:

{
  # https://devenv.sh/basics/
  env.AWS_PROFILE = "123456789"; 

  # https://devenv.sh/packages/
  packages = [ pkgs.nest-cli ];

  # https://devenv.sh/scripts/
  scripts.tests.exec = "npm run test";
  scripts.e2e.exec = "npm run test:e2e";
  scripts.lint.exec = "npm run lint";
  scripts.docker-build.exec = "docker build -t invoice-upload .";
  scripts.docker-run.exec = "./test/test_run.sh";

  # https://devenv.sh/languages/
  languages = {
    javascript = {
      enable = true;
      package = pkgs.nodejs_18;
      npm.install.enable = true;
    };
    typescript.enable = true;
  };

  # Enabling dotenv is helpful for bootstrapping the environment for local testing
  # but will interfere when interacting with the remote git repository
  dotenv.enable = false;
  dotenv.disableHint = true;

  # https://devenv.sh/pre-commit-hooks/
  # pre-commit.hooks.shellcheck.enable = true;

  # https://devenv.sh/processes/
  processes.test-containers.exec = "docker-compose -f test/docker-compose_local.yml up";

  # See full reference at https://devenv.sh/reference/options/
}

You can see quite similar features as compared to devbox, just in another format. We still declare packages, environment variables, scripts, shell hooks but devenv also exposes nice features like languages which makes setting up specific languages quite straightforward because sometimes you need to do more than just installing a package or two (like with Rust). There’s also support for dotenv, pre-commit hooks, long-running processes and more. You can find a list of supported languages and all the available options here. There’s also a list of supported services such as web servers or databases (see here).

All of this is quite comprehensive and well-made and I have yet to encounter any real issue. The biggest downside to the whole thing is that it’s not straightforward to choose a specific package version. By default the nixpkgs input pulls from the unstable channel. If you want something else than what’s available there, you need to add another input in the devenv.yaml. Courtesy of the people from jetpack (who make devbox) there are resources like this where you can look up the nixpkgs version that corresponds to a specific package version you might need. This is all possible but quite annoying. Fortunately, I haven’t needed to do this before but since one of the whole points of this setup is reproducibility it’s quite important.

Conclusion

Both of these tools are great and highly useful. Personally, I turned to devenv because I find it a bit more powerful and I prefer the Nix format over JSON for configuration. Of course this is highly subjective as both projects are under active development and changes will (or already have) happen(ed).

What I most love about these tools is that I can have a fairly simple setup for all my projects with all of the tools I need, declare everything that’s necessary to work on something and am able to just cd into a directory and then have everything magically come into being. I can then just start up an editor or IDE and get going. Granted, depending on what you’re working with, you wil probably have to point the IDE at the path where a runtime or executable lives within the Nix profile in your dev environment but that’s a minor nuisance since you only have to do it once.

Also, the config files can be checked in to version control so I don’t have to worry about having to set everything up from scratch again. How many times have you set up a dev environment of any kind with env variables, packages, config changes and whatnot only to forget what exactly you did? And then, six months later something changes, you have to reinstall, you get a new machine and you have no idea what you did to set up and have to work your way through it again. Sounds familiar? It definitely does to me!

Happy coding!