Declarative Dev Environments

by Marco - (13 min read)

I don't install development tools globally. I don't have node added to my PATH in my ~/.zshrc file, and running cargo outside a project folder returns "command not found." I wipe my computer on every reboot. With the exception of four folders (/boot, /nix, /home, and /persist), everything gets deleted. And it has worked out great.

Instead of installing development packages globally, I declare them as a dependency in my project's dev environment. They become available as soon as I cd into the project folder. If two projects use the same tool then I only keep one version of that tool on my computer.

I think installing dev tools globally is a bad pattern that leads to nothing but heartache and woe. If you are running sudo apt-get install or brew install prior to building a project, you are doing it wrong. By defining your dev tool dependencies explicitly you allow your projects to easily build on any machine at any point in time. Whether it's on a friends machine today, or a new laptop in 10 years. It even makes CI integration a breeze.

What do I mean by a declarative dev environment?

I mean a project that has a special file (or files) that define all the dependencies required to build and run your project. It doesn't necessarily have to include the actual binaries you will run in the repo, but it should be reproducible. If you clone my project you should be running the exact same tools as me.

Just like you have explicit dependencies on libraries you use in your program, a declarative dev environment lets you define your tooling dependencies (e.g. which version of Node, Yarn, or your specific cross compiler toolchain).

How I setup my declarative dev environments

To accomplish this I use Nix with Nix Flakes and direnv. There are three relevant files: flake.nix which defines the build of the project and the tools I need for development; flake.lock which is similar in spirit to a yarn.lock or Cargo.lock file, it locks the exact version of any tool used and generated automatically the first time you introduce dependencies; and finally a .envrc file which simply tells direnv to ask Nix what the environment should be, and sets up the environment when you cd into the folder. Here are some simple examples: flake.nix, .envrc (flake.lock omitted since it's automatically generated).

As a shortcut for setting up a flake.nix and .envrc, you can use a template to provide the boilerplate. When I start a new project I'll run nix flake init -t github:marcopolo/templates which copies the files from this repo and puts them in your current working directory. Then running direnv allow will setup your local environment, installing any missing dependencies through Nix as a side effect.

This blog itself makes use of declarative dev environments. Zola is the static site generator I use. When I cd into my blog my environment is automatically setup with Zola available for previewing the blog.

How Nix works, roughly

This all works off Nix. Nix is a fantastic package manager and build tool that provides reproducible versions of packages that don't rely on a specific global system configuration. Specifically packages installed through Nix don't rely an a user's /usr/lib or anything outside of /nix/store. You don't even need glibc installed (as may be the case if you are on Alpine Linux).

For a deeper dive see How Nix Works.

An example, how to setup a Yarn based JS project.

To be concrete, let me show an example. If I wanted to start a JS project and use Yarn as my dependency manager, I would do something like this:

# 1. Create the project folder
mkdir my-project

# 2. Add the boilerplate files.
nix flake init -t github:marcopolo/templates

# 3. Edit flake.nix file to add yarn and NodeJS.
# With your text editor apply this diff:
# -          buildInputs = [ pkgs.hello ];
# +          buildInputs = [ pkgs.yarn pkgs.nodejs-12_x ];

# 4. Allow direnv to run this environment. This will also fetch yarn with Nix
#    and add it to your path.
direnv allow

# 5. Yarn is now available, proceed as normal. 
yarn init

You can simplify this further by making a Nix Flake template that already has Yarn and NodeJS included.

Another example. Setting up a Rust project.

# 1. Create the project folder
mkdir rust-project

# 2. Add the boilerplate files.
nix flake init -t github:marcopolo/templates#rust

# 3. Cargo and rust is now available, proceed as normal. 
cargo init
cargo run

Here we used a Rust specific template, so no post template init changes were required.

Dissecting the flake.nix file

Let's break down the flake.nix file so we can understand what it is we are declaring.

First off, the file is written in Nix, the programming language. At a high level you can read this as JSON but with functions. Like JSON it can only represent expressions (you can only have one top level JSON object), unlike JSON you can have functions and variables.

# This is our top level set expression. Equivalent to the top level JSON object.
{
  # These are comments

  # Here we are defining a set. This is equivalent to a JSON object.
  # The key is description, and the value is the string.
  description = "A very basic flake";

  # You can define nested sets by using a `.` between key parts.
  # This is equivalent to the JSON object {inputs: {flake-utils: {url: "github:..."}}}
  inputs.flake-utils.url = "github:numtide/flake-utils";

  # Functions are defined with the syntax of `param: functionBodyExpression`.
  # The param can be destructured if it expects a set, like what we are doing here. 
  # This defines the output of this flake. Our dev environment will make use of
  # the devShell attribute, but you can also define the release build of your
  # package here.
  outputs = { self, nixpkgs, flake-utils }:
    # This is a helper to generate these outputs for each system (x86-linux,
    # arm-linux, macOS, ...)
    flake-utils.lib.eachDefaultSystem (system:
      let
        # The nixpkgs repo has to know which system we are using.
        pkgs = import nixpkgs { system = system; };
      in
      {
        # This is the environment that direnv will use. You can also enter the
        # shell with `nix shell`. The packages in `buildInputs` are what become
        # available to you in your $PATH. As an example this only has the hello
        # package.
        devShell = pkgs.mkShell {
          buildInputs = [ pkgs.hello ];
        };

        # You can also define a package that is built by default when you run
        # `nix build`.  The build command creates a new folder, `result`, that
        # is a symlink to the build output.
        defaultPackage = pkgs.hello;
      });
}

On Dev Tools and A Dev Setup

There is a subtle distinction on what constitutes a Dev Tool vs A Dev Setup. I classify Dev Tools as things that need to be available to build or develop a given project specifically. Think of gcc, yarn, or cargo. The Dev Setup category are for things that are useful when developing in general. Vim, Emacs, ag are some examples.

Dev tools are worth defining explicitly in your project's declarative dev environment (in a flake.nix file). A Dev Setup is highly personal and not worth defining in the project's declarative dev environment. But that's not to say your dev setup in not worth defining at all. In fact, if you are (or when you become) familiar with Nix, you can extend the same ideas of this post to your user account with Home Manager.

With Home Manager You can declaratively define which programs you want available in your dev setup, what Vim plugins you want installed, what ZSH plugins you want available and much more. It's the core idea of declarative dev environments taken to the user account level.

Why not Docker?

Many folks use Docker to get something like this, but while it gets close – and in some cases functionally equivalent – it has some shortcomings:

For one, a Dockerfile is not reproducible out of the box. It is common to use apt-get install in a Dockerfile to add packages. This part isn't reproducible and brings you back to the initial problem I outlined.

Docker is less effecient with storage. It uses layers as the base block of Docker images rather than packages. This means that it's relatively easy to end up with many similar docker images (for a more thorough analysis check out Optimising Docker Layers for Better Caching with Nix).

Spinning up a container and doing development inside may not leverage your existing dev setup. For example you may have Vim setup neatly on your machine, but resort to vi when developing inside a container. Or worse, you'll rebuild your dev setup inside the container, which does nothing more than add dead weight to the container since it's an addition solely for you and not really part of the project. Of course there are some workarounds to this issue, you can bind mount a folder and VS Code supports opening a project inside a container. ZMK does this and it has worked great.

If you are on MacOS, developing inside a container is actually slower. Docker on Mac relies on running a linux VM in the background and running containers in that VM. By default that VM is underpowered relative to the host MacOS machine.

There are cases where you actually do only want to run the code in an x86-linux environment and Docker provides a convenient proxy for this. In these cases I'd suggest using Nix to generate the Docker images. This way you get the declarative and reproducible properties from Nix and the convenience from Docker.

As a caveat to all of the above, if you already have a reproducible dev environment with a Docker container that works for you, please don't throw that all out and redesign your system from scratch. Keep using it until it stops meeting your needs and come back to this when it happens. Until then, keep building.

On Nix Flakes

Nix Flakes is still new and in beta, so it's likely that if you install Nix from their download page you won't have Nix Flakes available. If you don't already have Nix installed, you can install a version with Nix Flakes with the unstable installer, otherwise read the section on installing flakes.

Closing thoughts

In modern programming languages we define all our dependencies explicitly and lock the specific versions used. It's about time we do that for all our tools too. Let's get rid of the apt-get install and brew install section of READMEs.