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:
- Enable flakes on NixOS or install Nix with Flakes enabled on any other Linux and macOS.
- Install direnv. I personally use Home Manager to install this but of course feel free to use whichever method works for you. Optionally, you can also install nix-direnv, which provides a faster implementation of the Nix-related functionality in direnv.
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:
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
:
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:
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:
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
:
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
:
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:
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:
- Examine the actual contents of the flake to make sure nothing funny is going on.
- 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 useuse flake "github:the-nix-way/dev-templates/b2377c684479f6bf7d222a858e1eafc8a2ca3499?dir=rust"
to make the env truly declarative and overcome the ambiguity of the firstuse 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.