Image for post nix-direnv
Sep 23, 2022 by Luc Perkins

Effortless dev environments with Nix and direnv

Like many of you, I work on a lot of different projects. Even when a project is less serious—hey, I should check out this new JS framework!—I strive to reduce the friction involved with setting up the project’s dev environment to the absolute bare minimum possible. In this post, I’d like to tell you how I do that using two tools that have become indispensable to my daily workflows: Nix and direnv.

Prerequisites

If you’d like to follow along with this tutorial:

Nix shell environments

Before I go deeper into the fearsome combo of Nix and direnv, I’d like to say a bit about the Nix shell for those who aren’t yet familiar. Nix enables you to create fully declarative and reproducible development environments that are defined by:

  • Which Nix packages you want installed in the environment
  • Which shell commands you want to run whenever the environment is initialized

The standard function for creating shell environments is mkShell in Nixpkgs, but other functions are available in the ecosystem. Here’s a simple example:

{
  devShells.default = pkgs.mkShell {
    buildInputs = with pkgs; [go_1_19 terraform];
  };
}

When you enter this shell by running nix develop, Go version 1.19 and Terraform will both be available in the current directory (but not globally). With definitions like this, you can include every package you may need in the environment, which includes executables (like Go and Terraform) as well as things like language servers and editor configurations.

direnv and Nix environments

direnv is a tool that, whenever you navigate to a directory with a .envrc file in it, enacts the directives in that file to produce a directory-specific environment every time you navigate there. direnv has a lot of great features worth checking out, such as loading .env files and managing local Ruby versions, but today I want to focus on just one: the use flake directive. If you add this to your .envrc file, direnv activates the Nix shell defined in the local flake.nix:

use flake

Practically, this means that all you need to do to enter your Nix-defined development environment is to cd into the directory (unless you run direnv revoke, which causes direnv to ignore the .envrc until it’s reactivated using direnv allow). This is an elegant solution to a problem that we’ve all encountered where we navigate to a directory, run a setup command, and end up confused because the desired executable is missing or some other aspect of the environment isn’t what we want.

direnv with remote Nix environments

The use flake directive works great when the . points to a local flake defined by a flake.nix file, automatically loading the dev shell defined under the devShells.default output. But there’s actually an even faster way to use direnv for dev environments because use flake can point to any dev shell defined in any flake. Take this Node.js dev environment as an example:

{
  # Inputs omitted for brevity

  outputs = { ... }: {
    devShells.default = pkgs.mkShell {
      packages = with pkgs; [node2nix nodejs pnpm yarn];

      shellHook = ''
        echo "node `${pkgs.nodejs}/bin/node --version`"
      '';
    };
  };
}

The URL for this flake is github:the-nix-way/dev-templates?dir=node, so if we want to use this environment, we can add this to the .envrc file instead:

use flake "github:the-nix-way/dev-templates?dir=node"

If you run direnv allow in the directory, direnv gets to work using Nix to install the packages defined in this remote flake (Node.js, npm, etc.). That can take a while time the first time you enter the directory because Nix needs to install the environment’s dependency tree into the Nix store, but when you cd in the future it should be quite speedy.

But the potential fun doesn’t stop there. I’ve created, for example, my own little helper script for creating .envrc files that I call dvd:

echo "use flake \"github:the-nix-way/dev-templates?dir=$1\"" >> .envrc
direnv allow

So if I run .dvd go., for example, my shell immediately starts loading this environment, which includes Go version 1.19 and some commonly used Go tools (like goimports).

No Dockerfile, no Makefile, no local Nix logic, just a single line in a .envrc file can get you a specific, arbitrarily complex dev environment.

Layering environments

Because .envrc files are scripts, you can add as many use flake statements as you want, which enables you to layer Nix environments. Here’s an example .envrc:

use flake "github:the-nix-way/dev-templates?dir=elixir"
use flake "github:the-nix-way/dev-templates?dir=gleam"

In this case, both an Elixir and a Gleam environment would be applied to the current directory. This may not always be the best idea, as Nix honors the flake listed last in case of a clash, for example in the case of two packages named cargo that refer to different versions of Cargo. But if you apply due care, you can reap the benefits of layered environments without being stung by ambiguity.

Drawback

The major pro of using remote flakes for dev environments is that it enables you to initialize an isolated, pure dev environment about as quickly as you can hope for in the software world. For weekend projects and experiments it’s perfect. The downside is that relying on a dev environment defined in a remote flake doesn’t provide exactly what you need. Layering environments can add some specificity but for projects that require a fully custom environment, the approach in this post breaks down pretty quickly.

But never fear! There’s another great Nix feature that can help here: Nix flake templates. With templates, you can use the nix flake init command to initialize a Nix-defined template in the current directory. Personally, I use a script called dvt to initialize specific templates from my dev-templates project:

nix flake init -t "github:the-nix-way/dev-templates#$1"
direnv allow

So if I run dvt I get a specific .envrc, flake.nix, and flake.lock. These files provide a great basis for further customization. Let’s say that I need to start up a Python project but I also want to install watchexec. For that I can run dvt python, wait for direnv to load the environment, and then add watchexec to the packages in flake.nix.

Find out more about how Determinate Systems is transforming the developer experience around Nix

Security warning

One thing that you should always bear in mind when using remote flakes for dev environments is that you can add anything to packages and that shellHook allows for arbitrary code execution. If you add use flake "github:bitcoin-scam/gonna-mine-btc" to your .envrc you are going to be in for a rough time (and you could still be in for a rough time if you use a more innocuous-seeming flake). So there are some precautions you should always take:

  1. Examine the actual contents of the flake to make sure nothing funny is going on.
  2. Less obviously, if you’re using a version-controlled remote flake, make sure to “pin” to a specific reference. So instead of use flake "github:the-nix-way/dev-templates?dir=rust" you can use use flake "github:the-nix-way/dev-templates/b2377c684479f6bf7d222a858e1eafc8a2ca3499?dir=rust" to make the env truly declarative and overcome the ambiguity of the first use flake directive.

Broader implications

Using techniques like this has helped me to not just get up and going in dev projects more quickly than ever before—seriously, it almost feels like cheating—but also to declutter my home environment. I talked about this a little bit in a previous post, where I laid out an imperative that I’ve been following recently where I keep my laptop’s global environment as minimal as possible—just Git, jq, wget, and a few other executables that I truly need available everywhere on my system—while making everything else project specific.

As devs, we should spoil ourselves more. We should get used to dropping into projects willy-nilly and having them Just Work, and the processes that make them Just Work should fade into the background. The approach I laid out here charts what I take to be a reasonable course between convention and configuration. I certainly don’t expect you to make the same choices, but I do hope that the formidable combination of Nix flakes and direnv becomes an extra-sharp arrow in your daily dev quiver.


Share
Avatar for Luc Perkins
Written by Luc Perkins

Luc is a technical writer, software engineer, and Nix advocate who's always on the lookout for qualitatively better ways of building software. He originally hails from the Pacific Northwest but has recently taken to living abroad.