Tools You Should Know About: nix-shell
Recently, I've discovered how to leverage Nix to reap a lot of its benefits with a very minimal investment; specifically, one that fits in one blog post.
I've been aware of the Nix toolset for over a decade now, but until recently it's always looked like it required a pretty big investment. Most of the documentation I've come across explains what the benefits are and how the tooling works in order to deliver those benefits, but always from the perspective of wanting to build a project using Nix as your build system. And, well, I usually already have a build system I'm pretty happy with, and I've never bit the bullet and actually learned the pretty arcane syntax of the Nix language.
In a nutshell
From the homepage:
The command
nix-shell
will build the dependencies of the specified derivation, but not the derivation itself. It will then start an interactive shell in which all environment variables defined by the derivation path have been set to their corresponding values, and the script$stdenv/setup
has been sourced. This is useful for reproducing the environment of a derivation for development.
There you go. Now, let me explain in plainer terms why that's awesome and how you can leverage it immediately after you finish reading this blog post.
Why you should know about it
Most software projects have what I'd call "tool dependencies". In many languages, you have your more explicit "library" dependencies, usually managed by a language-specific package manager (gem for Ruby, Maven for Java, cargo for Rust, npm for JavaScript, etc.). To be clear, I am not, in this post, advocating replacing any of those with Nix.
But there's a layer under that: the language runtime or compiler, the language-specific package manager itself, all sorts of extra little scripts most projects tend to grow over time. Perhaps a linter, or some infrastructure-related tooling. Maybe the project contains scripts that only work with some versions of Bash. Maybe you need jq. Those are the types of dependencies I'm concerned with in this post.
In my experience, managing those tooling dependencies is usually left entirely to individual developers, based on some vague instructions in the README when you're lucky. This is not great for many reasons:
- Developers need to figure out how to install the various tools the project needs, and sometimes even just what those tools are.
- People may be adding dependencies without realizing it: when writing a new script, it's easy to accidentally depend on some CLI tool that's present on your machine, but maybe isn't on everyone's.
- Most tools are installed "globally" on a machine. For example, on most OSes it's awkward to juggle multiple JVMs, or multiple versions of Node, or multiple versions of Bash or curl.
Ad-hoc solutions have been invented for that last point, such as rbenv and nvm. Those tend to be language-specific and only provide you with an easy way to manage your language installations (respectively Ruby and Node for rbenv and nvm). They typically don't do anything for other tools, and if you're working on a multi-language project you may have to contend with multiple such tools.
While that isn't the problem nix-shell was built to solve, it solves it
beautifully. nix-shell
is for you if you like any of these properties:
- You don't want to worry about tooling for one project interfering in any way with tooling for other projects.
- You always want to have the expected tools (with their expected versions) for every project you work on, with no extra effort expended on your part figuring out either what those tools are or how to install them.
- You want to be confident everyone working on a project (for a value of "everyone" that covers macOS and Linux users; I'm not aware of any Windows support for Nix), including CI machines, is using the same set of tools down to the specific version.
Feature highlights
The Nix project as a whole has many parts, but this post focuses on one very specific way of using one of the tools in the (vast) Nix toolbox. Therefore, there's a single highlight: the ability to get a reproducible (across time as well as across machines) environment for either running a script or opening a shell.
A bit of context
The Nix project aims at building a completely reproducible world. "Nix" is a reproducible package manager (/ build system), as well as the name of the (purely functional) programming language used to define the corresponding "build rules", or "packages", called "derivations" in the Nix world; "NixOS" is a Linux-based operating system built around the Nix package manager, while the NixOps project brings the same level of reproducibility to infrastructure management.
When used outside NixOS, the Nix package manager can run alongside your OS's
package manager (apt, yum, brew, etc.) without interfering with it in any way.
The Nix package manager stores all of its packages under the /nix/store
path,
and packages under that path are named by the hash of their dependencies.
Transitively, this is what allows Nix to manage the coexistence of many
different versions of the same package, be that different versions of the
source code or the same source code built against different sets of
dependencies. Installing things with Nix is pretty easy; the tricky part is to
get those weird hash-looking paths in your PATH
. And that's where Nix
tutorials usually want to take over your entire machine so they can manage your
shell.
This is where nix-shell
comes in. You can live in a world where your default
state is to compeltely ignore your Nix installation, and then only add specific
Nix packages to your PATH
when working on specific projects.
The other question that this should raise is how do you know what hash to install in the first place. The answer to that is that Nix works with a repository of Nix "recipes" called nixpkgs, which is a GitHub repo that contains "derivations" for many, many, many programs. The way you find out specific hashes is that you tell Nix to use a specific commit from that repo as your "packages definitions", and from then on you only need to specify the package name (and sometimes some options) and Nix will figure out the appropriate hash itself.
How it works in practice
Installing Nix
First, you need to have Nix installed. This is a one-time action,
and on macOS you'll need sudo
access to do it. (You can do a "single-user"
install on Linux without giving sudo
access to the install script if you
manually create a writeable /nix
before running the install script).
Initializing a project
To initiate a new project where Nix is in charge of your dependencies (or add a nix-shell configuration to an existing project), run:
nix-shell -p niv --run "niv init -b nixpkgs-unstable"
This will create a Nix configuration in the directory ./nix
that specifies
the revision for nixpkgs
as the latest commit on the nixpkgs-unstable
branch at the time of running that command. (There may be reasons to choose
other branches, but if you're not familiar with Nix, nixpkgs-unstable
is a
good default.)
Next, we'll want to create a shell.nix
file, with content that looks like
this:
let
sources = import ./nix/sources.nix;
pkgs = import sources.nixpkgs {};
in
pkgs.mkShell {
buildInputs = [
pkgs.bash
pkgs.jdk8
pkgs.terraform
];
}
This is Nix language for "load the definitions from nixpkgs
, and build the
buildInputs
list."
At this point, if you had started in an empty directory, this should look like this:
$ tree -a
.
├── nix
│ ├── sources.json
│ └── sources.nix
└── shell.nix
1 directory, 3 files
$
where sources.json
and sources.nix
have been created by niv
.
And that's pretty much it. You can now run scripts using this environment:
$ nix-shell --run "java -version; terraform -v; bash --version"
openjdk version "1.8.0_292"
OpenJDK Runtime Environment (Zulu 8.54.0.21-CA-macosx) (build 1.8.0_292-b10)
OpenJDK 64-Bit Server VM (Zulu 8.54.0.21-CA-macosx) (build 25.292-b10, mixed mode)
Terraform v1.1.2
on darwin_amd64
GNU bash, version 5.1.8(1)-release (x86_64-apple-darwin17.7.0)
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
$
You can open up a new (Bash, by default) shell with those three tools loaded in
PATH
:
$ terraform -v
zsh: command not found: terraform
$ nix-shell
[nix-shell:/tmp]$ terraform -v
Terraform v1.1.2
on darwin_amd64
[nix-shell:/tmp]$ exit
exit
$
By default, the nix-shell
command will add its buildInputs
to the current
PATH
, which is usually what you want for local development (so you can still
access your own, personal, project-independant tools like emacs
or vim
).
But, particularly on CI, you may want to run some script using only the
nix-shell
-provided environment so you can make sure it's "complete". You can
do that with the --pure
option to nix-shell
, which resets the PATH
to
just the buildInputs
, and clears most environment variables. For example:
$ nix-shell --pure --keep GITHUB_TOKEN --run ./my-script.sh
would run my-script.sh
using just the configuration in the local shell.nix
file, clearing all environment variables except GITHUB_TOKEN
(--keep
can be
specified multiple times to keep multiple env vars).
direnv
nix-shell
as presented in the previous section would alread be pretty nice,
but combined with direnv it's a real game changer. Assuming you have direnv
installed (perhaps because you read my post about it), you can
add an .envrc
file to the project above with this content:
use nix
source_env_if_exists .envrc.private
to automatically load the shell.nix
dependencies whenever you cd
into the
project, and unload them when you cd
out. If you work on multiple projects
that use slightly different versions of many dependencies, having such a setup
in each of them makes it really easy.
Updating the nixpkgs
revision
Besides not having to look up the current commit on the tip of the
nixpkgs-unstable
branch, using niv
to manage the nixpkgs
revision allows
you to later upgrade to the current latest with:
nix-shell -p niv --run "niv update"
which will automatically update nix/sources.json
to point to the latest
version at the time of running.
Where to find package names
One of the most recurrent problems in my work when setting up machines is to
find out what the name of a package is, when I already have a pretty good idea
of what it should be. Many package managers have some sort of search feature
for that, but for Nix, the way I usually go about it is to check this
file in the nixpkgs repository. The names on the left of the
equality operators are the top-level package names that you can access through
the pkgs.
prefix in the shell.nix
file sample above.
nix-shell
shebang
If you can assume everyone using your project is going to have Nix installed,
but may not be using direnv
(or you don't want to setup direnv
on CI, for
example), you can use nix-shell
as your shebang with a syntax that looks
like:
#!/usr/bin/env nix-shell
#!nix-shell -i bash shell.nix
The first line instructs your OS to execute the file with nix-shell
; the
second line is parsed by nix-shell
itself and in this case indicates it
should run the file through the bash
interpreter after having loaded the
environment described in shell.nix
. You may want to add a --pure
argument
on that second line in some circumstances.
Conclusion
Hopefully by this point you know how to use Nix to provide reproducible toolsets for your projects. My point is not that there is nothing else to learn about Nix; time invested learning more about it would be well spent. But I hope to have made the point that you can leverage it very easily, even with little investment.
I myself can't think of any reason not to add both direnv
and nix-shell
to every new project I create from now on, so that's what I'm going to do by
default.