Nix can launch developer environments

 · 6 min · torgeir

Nix can launch developer environments with all the programs your build or project needs, readily available on the path. With nescessary environment variables set and custom scripts at your fingertips.

Nix Terminal

This post is part of the Series: Nix can.

You can accomplish this in a few ways with nix. This post will consider one of the simplest ways I have seen this done, yet, which involves a file called shell.nix and the tools direnv + nix-direnv.

shell.nix

Create a file shell.nix in you project folder. Import and instantiate nixpkgs, and use pkgs.mkShell to create a shell with what’s listed under packages available.

{ pkgs ? import <nixpkgs> { } }:
with pkgs;
mkShell { packages = [ cowsay ]; }

Run nix-shell.

A bash shell is launched, with cowsay available.

[nix-shell:~/Projects/cowsay]$ cowsay awyea
 _______
< awyea >
 -------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Hit Control-d to exit. The shell is gone, and so is cowsay.

> which cowsay
cowsay not found

Its not really gone, though. What was installed is still in the /nix/store/, but its no longer on your path. And it does not conflict with other programs you might have installed or their dependencies. It will also be garbage collected, automatically (depending on your nix configuration) or when you run nix-collect-garbage - which is not optimal, as it would cause reevaluation of your developer environment again the next time you need it.

> ls -a /nix/store/ | grep cowsay
n1lnrvgl43k6zln1s5wxcp2zh9bm0z63-cowsay-3.7.0
a1ldaam46yvas4ncahnj771y0n0c0jdy-cowsay-3.7.0.drv

Another quirk of the previous approach is that you are dropped into a bash shell. Developers love customizing their shells, be it starship.rs or powerlevel10k. You might have custom shell functions you use, or useful zsh plugins installed that you need in your development flow. Being dropped into someone else’s shell is inconvenient.

direnv + nix-direnv

direnv is a shell extension that will load environment variables you put in a local .envrc file into the current shell when you enter into the directory. When you leave the directory the changes are unloaded. Its nice, but it only affects environment variables or the subshell that it runs in.

As it can affect the PATH, it can also integrate with nix, in a couple of ways. This can allow nix to manage and load your developer environment when you enter the folder, and clean it up from cluttering your machine globally when you exit the folder.

direnv by default provides the use nix directive. It works fine, but suffers from slow startup speeds and your setup will disappear and need reevaluation when the nix garbage collection runs.

nix-direnv overrides use nix to provide a faster experience, that will cache the generated environment setup and also prevent it from being garbage collected 1.

Setup

Put an .envrc file with the use nix declaration inside it alongside your shell.nix to make direnv use nix-direnv.

# ~/Projects/cowsay/.envrc
use nix

Run direnv allow to approve the new/changed .envrc file, to allow for direnv to load it.

> direnv allow
direnv: loading ~/Projects/cowsay/.envrc
[0/1 built] direnv: ([/etc/profiles/per-user/torgeir/bin/direnv export zsh]) is taking a while to execute.
Use CTRL-C to give up.
...

Immediately, nix will pull down what shell.nix says it should. When it completes, you can cowsay again!

> cowsay awyea
 _______
< awyea >
 -------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

What’s nice about this approach is that it keeps you in your current shell. When you cd out of the directory, cowsay is gone (from your $PATH).

> cd ..
direnv: unloading

When you cd into it again, its back!

> cd ~/Projects/cowsay/
direnv: loading ~/Projects/cowsay/.envrc
direnv: nix-direnv: using cached dev shell
direnv: export +AR +AS +CC +CONFIG_SHELL <and many many more>
.. all other environment variables nix brings in to make this work ..
> cowsay yiha
 ______
< yiha >
 ------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
               ||----w |
               ||     ||

Installing nix-direnv with nix

You can install direnv and nix-direnv using the home-manager module for direnv.

# home/direnv.nix
{ ... }: {
  programs.direnv = {
    enable = true;
    nix-direnv.enable = true;
  };
}
# home/default.nix
{ inputs, pkgs, ... }: {

  imports = [
    ./direnv.nix
  ];

}

Shell hooks and custom scripts

By use of the shellHook, you can instruct nix to run commands as part of your shell initialization. Here is an example that installs a specific version of node, and creates a shell script that will use it to show the node version.

# shell.nix
{ pkgs ? import <nixpkgs> { } }:

pkgs.mkShell {

    packages = [
        pkgs.nodejs_20

        (pkgs.writeScriptBin "what-the-node" ''
         ${pkgs.nodejs_20}/bin/node --version
         '')
    ];

    shellHook = ''
    echo "Node is version: $(what-the-node)"
    '';
}
direnv: loading ~/Projects/nodejs/.envrc
direnv: using nix
direnv: nix-direnv: using cached dev shell
Node is version: v20.10.0
direnv: export ...

From the same folder, the binary what-the-node will be available to run directly.

> what-the-node
v20.10.0

Super useful for codifying often used commands in a repository that most of the time ends up in a readme file. Notice how it needs no ./ prefix - its directly available as its on your path.

Pinning repositories for reproducability

While depending on something like nodejs_20 through import <nixpkgs> {} would always give you the current version of node v20.y.z from Nixpkgs, you will get updates with new versions of node v20 as they appear on Nixpkgs. So it is not fully reproducible.

You can make it reproducible by pinning nixpkgs to what exists at a given time, or rather at a given ref. Like most things with nix this also can be done in a few ways. Here’s one that fetches an archive with the given ref and sha from Github:

# shell.nix
{ pkgs ? import (fetchTarball
  { url = "https://github.com/NixOS/nixpkgs/archive/0096264659df9fa33c46b4d428f07cda83103a4d.tar.gz";
    sha256 = "1imyvlpjln3imibmaly9mr89yap7189ysr6z693hzcqjmvp8s266";
  })
  { } }:


pkgs.mkShell {

  packages = [
    pkgs.nodejs_20

    (pkgs.writeScriptBin "what-the-node" ''
        ${pkgs.nodejs_20}/bin/node --version
      '')
  ];

  shellHook = ''
    echo "Node is version: $(what-the-node)"
  '';
}

You can fetch the sha256 using nix-prefetch-url.

nix-prefetch-url https://github.com/NixOS/nixpkgs/archive/0096264659df9fa33c46b4d428f07cda83103a4d.tar.gz

This will give you a predictable version of nixpkgs for all the foreseeable future, that will always pull the same version of node v20, no matter when you run it.

If you wanted to lock some packages to a version from a specific time and let others slide, you could. You can accomplish this by defining multiple different package sets (i.e. pkgs in the example above) pointing to different sources. You could also choose to pull some packages from other repositories, your own forks, or e.g. the nixpkgs-unstable branch of NixOs/nixpkgs, if you really needed the latest bleeding edge version available.

But wait, there’s more!

This is already great stuff from a developer’s perspective.

  • Reproducible and predictable environments
  • Same setup and versions of installed software for all developers
  • Folder-scoped, unified interface for scripts to run common tasks; launch services, databases, queues ++
  • You could also fetch scripts or run shared setup code across repos for reuse, without needing developer intervention (no more “just clone this here, so this repo finds it”)

With an experimental feature called Flakes (still experimental since 2021), Nix has even more tricks up its sleeve regarding developer environments. Cachix, the creators of the nix binary cache solution, also created devenv.sh for an even more streamlined approach to developer evironments.

I’ll have to take a look at those some time later! 👀

Resources


  1. Nix-direnv prevents this environment from being garbage collected by symlinking the result into the nix /nix/var/nix/gcroots folder, to make nix view what it installs as a root with dependencies, that prevents it from automatic deletion. ↩︎