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
- https://nixos.wiki/wiki/Development_environment_with_nix-shell
- https://determinate.systems/posts/nix-direnv
- https://github.com/DeterminateSystems/zero-to-nix/
- https://github.com/cachix/devenv
- https://devenv.sh
- https://discourse.nixos.org/t/direnv-use-flake-nix-according-to-availability/29825
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. ↩︎