About using Nix in my development workflow
This article was originally published on Medium.
TL;DR If you don’t want to read how I’ve got to use Nix and general information about it but only focus on its use to setup a development environment, please jump to Using Nix. Even from that part, the reading can take some time as I wanted to share what I’ve learned and found useful accross two months of intensive usage. I had some initial questions I have answered to after reading a great portion of the documentation or asking to people. I wanted to share them with you so you can get into using Nix quickly, still understanding what you are doing.
Two years ago, whenever I needed to use some language or tool which wasn’t available on my machine, I would have used the system’s package manager to install it. It worked on my computer, but when I needed to reproduce the setup elsewhere it was another story. I was still a student then.
I remember a group project in which we were using Node.js. I had a recent version installed on my Mac via Homebrew, while at the university and on other student’s computer based on Ubuntu Linux, it was installed from the default repos. Then, my code using default parameters in functions would run on my machine but not on other’s, since their Node.js version was greatly outdated.
Using this approach can quickly become a nightmare, especially when you try to write a documentation on how to set up the development environment. You end up writing things like: “If you are on macOS please do this, on Debian do that, and Fedora run this other command.” You can verify it works now, but there is no guarantee it will still work in a few months.
asdf
At the end of my studies, while starting my internship, I started to do a lot more software development at home. Mainly because I didn’t have to worry anymore about not doing my homework. This is back then I discovered Elixir and started to build up my workflow, seeking for quality, reproducibility and joy of use. With my Node.js story in mind, I discovered language version managers. I went for asdf as it would handle for me quite a bunch of languages, including Erlang, Elixir, Node.js and Ruby.
If you are not familiar with it, asdf can install exactly the version you want
for each language it handles through a plugin system. You can even have multiple
versions installed on the same machine and set the wanted one for every project
using a .tool-versions
file which looks like this:
erlang 21.0
elixir 1.7.3-otp-21
It is simple to use, and you can set up easily your build environment on
different machines, running asdf install
in a project directory.
Yet, if the setup process is simplified, there are still issues:
- after installing asdf, you need to install all the needed plugins. It is only
a matter of
asdf plugin-install <plugin-name>
commands, but it is not automated; - some languages require dependencies to build. Erlang, for instance, needs some
packages to be installed before
asdf install erlang <version>
works. You need a compiler, ncurses, OpenSSL and quite a few more. Setup instructions depends on the system you use; - If your project depends on other tools like, say,
fwup
for Nerves projects, you still have to provide manual setup instructions.
This is where Nix enters the game.
Nix
Nix defines itself as The Purely Functional Package Manager. Having learned the advantages of Functional Programming while using Elixir and looking at Haskell and other FP languages over the past year, its concept was very tempting to me.
Being purely functional means given an input, you always get the same
output. That is, given a version of nixpkgs
and a set of packages, you
always get the same environment. In fact, packages are named derivations in
the Nix jargon: they are functions that takes other derivations—their
dependencies—as input and produce a derived result. They are built in isolation,
so all dependencies must be explicitely stated. This ensures
reproducibility.
Nix stores all the built derivations in the Nix store, usually located at
/nix/store
. A same package can be present multiple times in the Nix store, at
different versions or even at the same version but using different versions of
its dependencies. Remember: a built derivation is the product of all its
dependencies; if you change something, it is a different product.
To achieve a unique naming for each derivation, a hash is computed from the set of its dependencies. You then get a path like:
/nix/store/k13mm9jqxm2ndlwzsj7zicsq7lpmmjlg-elixir-1.7.3
Unlike other package managers, Nix does not use the conventional
/{,usr,usr/local}/{bin,sbin,lib,share,etc}
directories. Instead, it uses a lot
of symbolic links to create profiles. A profile is a kind of derivation used to
setup a user environment. In a profile, you get a standard Unix tree with
symbolic links to executables and configuration files stored in other derivation
outputs. For instance, ~/.nix-profile/bin/elixir
is a symbolic link to:
/nix/store/k13mm9jqxm2ndlwzsj7zicsq7lpmmjlg-elixir-1.7.3/bin/elixir
Actually, ~/.nix-profile
is itself a link:
~/.nix-profile -> /nix/var/nix/profiles/per-user/***/profile
Which itself points to profile-56-link
wich finally points to somewhere in the
Nix store:
profile-56-link -> /nix/store/5yw8dnp9908ia6sdfvx01jzis4l2hni7-user-environment
That is, as I have said above, a profile is a derivation. It derives from a set of packages, that themselves derive from other packages. Depends on becomes in Nix derives from. This is conceptual but you get the idea.
Updating a symbolic link has the interesting property of being an atomic
operation. This enables atomic transactions: when performing an upgrade, a new
user-environment
derivation is built, with a different hash. Then, a new
generation is created for the profile—understand: a new symbolic link
profile-57-link
pointing to the new derivation. Then, and only then, the
profile
link is updated to point to profile-57-link
. You’ve just performed
an atomic upgrade. If things went south, you’ve also got atomic rollbacks for
free: just update again the profile
link to point to profile-56-link
and you
are back in the past.
Moreover, only what you asked for is made available in the environment. For instance, Elixir depends on Erlang. Erlang is then installed somewhere in the Nix store and the Elixir installation is aware of it so it can work correctly. But unless you explicitely asked to also install Erlang, only Elixir binaries will be linked in your user environment.
Nix as a declarative configuration manager
Package managers usually work in an imperative way. That is, you ask them to install this, to perform an upgrade or to uninstall that. One really neat feature of Nix is Nix, the language. It is a purely functional domain-specific language that comes with Nix.
The primary use of Nix, the language, is to write derivations. Yet, different
applications of Nix also leverage the language to manage packages and
configuration declaratively. In NixOS—a special
GNU/Linux distribution based on Nix—, all the system configuration and globally
installed packages are declared in /etc/nixos/configuration.nix
. It can look
like:
{ config, pkgs, ... }:
{
network.hostname = "nixos-test";
time.timeZone = "Europe/Paris";
# Omitting a lot of options, this is just a sample.
environment.systemPackages = with pkgs; [
curl
git
gnupg
htop
...
];
}
To change the state of the system, you just have to edit the file, then ask NixOS to switch to the new environment:
# nixos-rebuild switch
It derives a new system environment, switches to it and reloads services as needed. With this kind of configuration, you can easily reproduce your system setup on different machines.
Another example is home-manager
. It
aims to provide the same kind of declarative configuration as NixOS but at the
user level. I personally use it to
manage my dotfiles and my set of user-wide-available utilities accross different
machines.
Apart system and user environments, Nix can be used to setup a third kind of environment. Let’s talk about it.
Nix as a reproducible environment builder
It is neither practical nor wantable to update your system or user environment each time you need a particular dependency for a given project. First, the given dependency can be required only by a specific project: you do not need to have it available (system|user)-wide. Second, you want your environment to be shareable with other developers. Asking them to update their global environment with this or that requirement is not the best thing to do. This is where Nix shells enter.
A Nix shell is a temporary environment where build inputs of a derivation are
made available to the user. Let’s say it more simply with an example: you can
create a shell.nix
file at the root of your project containing something like:
{ pkgs ? import <nixpkgs> {} }:
with pkgs;
mkShell {
buildInputs = [ ocaml git ];
}
Then, run the nix-shell
command. You are in a Nix shell, with OCaml and Git
made available to you. The shell.nix
can be committed, thus shared between
developers.
Using Nix
To start working on a project using Nix, the first step is to install Nix itself. From the official documentation, all you have to do is running:
$ curl https://nixos.org/nix/install | sh
There are in fact two kinds of installation for Nix:
- the single-user installation, where
/nix/store
is owned by the user installing Nix. This is the simplest way to install Nix if you are the only user on your machine and don’t want to use Nix extensively outside of setting your development environment. It is also the easiest to uninstall as you just have to delete the/nix
directory; - the multi-user installation, where
/nix/store
is owned byroot
and anixbld
group has write access to it. All Nix operations are then performed bynix-daemon
. Different users can use Nix simultaneously and you gain a system environment like on NixOS. In the multi-user installation, builds are performed by special builder users in complete isolation. It is a little bit more complex to manage, but this is the recommended way to install Nix if you plan to use it a lot, even if you are the only user on your machine. It is supported on macOS and all Linux runningsystemd
with SELinux disabled.
As far as I know, the default installer currently choses automatically the type of installation. It could change in the near future to prompting the user for a choice. In the meantime, you can force how to install Nix by doing, for a single-user installation:
$ sh <(curl https://nixos.org/nix/install) --no-daemon
For a multi-user installation it is:
$ sh <(curl https://nixos.org/nix/install) --daemon
Nix being installed on you machine, you can create a shell.nix
in your
project:
# This defines a function taking `pkgs` as parameter, and uses
# `nixpkgs` by default if no argument is passed to it.
{ pkgs ? import <nixpkgs> {} }:
# This avoids typing `pkgs.` before each package name.
with pkgs;
# Defines a shell.
mkShell {
# Sets the build inputs, i.e. what will be available in our
# local environment.
buildInputs = [ elixir git ];
}
Then, all you have to do is running:
$ nix-shell
Nix will copy or build derivations, then run a shell in which Elixir and Git are
available. By default, the Nix shell is a bash
. If you are like me and want to
keep your good old zsh
for your day-to-day environment, there is an
interesting plugin for that.
In a standard Nix shell, your system-wide environment is still available. While it is quite useful for day-to-day work, you can easily miss a dependency when first building your environment. If you want to be sure not to miss any dependency and ensure reproducibility, you should run a pure Nix shell, that is a Nix shell where only the inputs explicitely stated in the shell.nix are available:
$ nix-shell --pure
You loose all your environment, all your aliases, all your usually available
programs. You are in a standardised bash
that will be the same on every
machine where you run a pure shell. It is not a comfortable place to work, but
it is really comfortable when your application builds in such a shell. It means
any other developer or the future you will be able to setup the same environment
and build the application as expected. But for your comfort, you can use a
standard Nix shell most of the time.
In addition to Nix shells, you may sometimes want to make a tool globally
available in your user environment. If you don’t use
home-manager
to manage it in a
declarative way, you always can do it in an imperative way:
$ nix-env -i <package>
If you are looking for a better user experience in installing packages,
nox
is the way to go:
$ nix-env -i nox
Then just call nox
with some search string:
$ nox gcc
1 avr-gcc-8.2.0 (nixpkgs.avrgcc)
GNU Compiler Collection, version 8.2.0 for AVR microcontrollers
2 gcc-wrapper-7.3.0 (nixpkgs.gcc)
GNU Compiler Collection, version 7.3.0 (wrapper script)
3 gcc-arm-embedded-6-2017-q2-update (nixpkgs.gcc-arm-embedded)
Pre-built GNU toolchain from ARM Cortex-M & Cortex-R processors (Cortex-M0/M0+/M3/M4/M7, Cortex-R4/R5/R7/R8)
4 gcc-7.3.0 (nixpkgs.gcc-unwrapped)
GNU Compiler Collection, version 7.3.0
5 gcc-wrapper-4.8.5 (nixpkgs.gcc48)
GNU Compiler Collection, version 4.8.5 (wrapper script)
6 gcc-wrapper-5.5.0 (nixpkgs.gcc5)
GNU Compiler Collection, version 5.5.0 (wrapper script)
7 gcc-wrapper-6.4.0 (nixpkgs.gcc6)
GNU Compiler Collection, version 6.4.0 (wrapper script)
8 stdenv-darwin (nixpkgs.gcc7Stdenv)
The default build environment for Unix packages in Nixpkgs
9 gcc-wrapper-8.2.0 (nixpkgs.gcc8)
GNU Compiler Collection, version 8.2.0 (wrapper script)
...
Packages to install:
It will print you different alternatives so you can choose which one to install
directly by entering its number. It is also a good way to quickly search a
package name before adding it to a shell.nix
.
In the example above, you can see several packages are available for gcc
. The
real package name to include in a shell.nix
is the one in parentheses. If
you’ve done with pkgs;
, pkgs
being by default nixpkgs
in the shell.nix
examples I’ve shown you before, you can then omit nixpkgs
. from package names.
Keeping nixpkgs up to date
When you first install Nix, it also automatically installs the nixpkgs
channel
for you. nixpkgs
is the main channel in
the Nix community, and efforts are concentrated to it. You can event participate
if you want as it is as easy as opening a pull-request on GitHub. You are likely
willing to update it sometimes to get fresh versions of your packages. To do so,
run:
$ nix-channel --update
Don’t forget to run the command as the user managing nixpkgs
. If you’ve done a
single-user installation, it is your standard user. In a multi-user
installation, root
is responsible for managing this channel, so you must run
the command as root
.
If you have installed derivations via nix-env -i
or nox
, once you have
updated nixpkgs
, you should run nix-env -u
to rebuild them—understand:
update them—on top of the last nixpkgs
. If you have installed derivations on
NixOS at the system level, or at the user level using home-manager
, you have
to run nixos-rebuild switch
or home-manager switch
to rebuild your
environment.
When you update nixpkgs
, remember that you are changing an input. The
environments you build are now using derivations from this new version. It is
not fully reproducible since you can’t now which version of nixpkgs
another
user have. In most cases, this is not an issue. Packages that could cause
incompatibilities between versions, like languages or libraries, often provide
different packages ready to use. For instance, while elixir
provides a given
version of Elixir—theorically the last one—built on a given version of Erlang
that is not fixed and will evolve with nixpkgs
updates, there is
beam.packages.erlangR21.elixir_1_7
which provides you the latest Elixir 1.7 on
the latest Erlang 21. At the time I am writing this article, it is namely Elixir
1.7.3 on Erlang 21.0. Yes, a nixpkgs
update could bring you to Elixir 1.7.4 on
Erlang 21.1, say, but they are compatible versions. Using this derivation, you
will never end up with Elixir 1.8 or 2.0. However, if you really want to set
nixpkgs
to a given version to provide a fully-reproducible environment, this
is possible. All you have to do is setting the pkgs
variable differently in
your shell.nix
:
let
pkgs = import (fetchTarball {
url = https://github.com/NixOS/nixpkgs/archive/<rev>.tar.gz;
}) {};
# I’m also showing here you can define any variable.
elixir = pkgs.beam.packages.erlangR20.elixir_1_6;
in
with pkgs;
mkShell {
# `elixir` is not the latest Elixir anymore, but the latest
# version of the 1.6 branch built on the latest Erlang 20 at the
# time when `nixpkgs` was at <commit-hash>, as stated above.
# Same for the version of Git.
buildInputs = [ elixir git ];
}
Collecting garbage
As we have seen before, Nix stores the derivations outputs in /nix/store
. It
then builds environments linking to files in the store. But what happens when
you update, uninstall a program or when you exit a Nix shell? Nothing. New
environments are built, but nothing is removed from the store. After some time
of use, the Nix store can grow a bit too much for your taste. While it is good
to keep a cache between two nix-shell
calls to avoid fetching again all
dependencies, it can be useful to clean a bit the store sometimes.
Nix has a built-in garbage collector that looks for unreferenced derivations—derivation that are not part of an environment or a running Nix shell. You can call it by running:
$ nix-collect-garbage
Doing this has nevertheless a limitation: it does not clean old generations of profiles. Remember: when doing changes to environments, you don’t mutate its state: you create a new generation and switch to it. This is what makes rollbacks possible. To reclame space on disk, old generation need to be removed. You can ask the garbage collector to delete them before to collect garbage:
$ nix-collect-garbage -d
However, it is a good pratice do keep old generations for some time, just in case. You can tell the garbage collector to delete old generation only if they are older than a given amount of time. To keep old generation 30 day, for instance, you would do:
$ nix-collect-garbage --delete-older-than 30d
Garbage collection has one downside though: as shell environments are not
symlinked in the GC roots like profiles, the garbage collector systematically
deletes them. In the next section about direnv
, we’ll see there is a way to
persist them and avoid this kind of issue.
Apart from collecting garbage, there is another way to optimise the Nix store in term of disk space. When some derivation gets updated, all derivations depending on it have to be rebuilt since one of their inputs has changed. Many of their files—if not all—remain the same, so it is possible to reclaim space by hard-linking them:
$ nix-store --optimise -v
You can also make Nix auto-optimise its store when writing new files to it:
$ mkdir -p ~/.config/nix
$ echo "auto-optimise-store = true" >> ~/.config/nix/nix.conf
Please use this option with caution though. If it works really well on a
single-user installation, I’ve seen race conditions on /nix/store/.links
files
creation when using several builder simultaneously. I prefer to run it by hand
then. With a good alias like
nso
it’s quick.
direnv
While Nix provides a much richer developer experience, asdf has a very comfortable feature: you don’t have to run a command each time you want to get your environment configured. The right versions of your languages are automatically picked up depending on which directory you are in. As Nix lacks of this feature—is not its role anyway—let me introduce direnv.
direnv is a tool for automatically switching between environments, based on your current directory. To cite its documentation:
Before each prompt, direnv checks for the existence of a “.envrc” file in the current and parent directories. If the file exists (and is authorized), it is loaded into a bash sub-shell and all exported variables are then captured by direnv and then made available to the current shell.
One really neat thing about direnv is its awareness of Nix. You can just put
use nix
in a .envrc
and direnv knows how to update you current shell to behave
like a Nix shell. Let’s start with installing direnv:
$ nix-env -i direnv
For direnv to work, you then need to hook it into your shell. Edit your
~/.<shell>rc
and put:
eval "$(direnv hook <shell>)"
Replace <shell>
with zsh
, bash
or any other supported
shell.
In any project directory, you can then create a .envrc
by running:
$ direnv edit .
As your environment configuration is already handled by the shell.nix
, simply
tell direnv to use Nix by writing:
use nix
After saving the file and quitting your editor, direnv should automatically update your shell environment whenever you enter or exit the project tree.
If you change the .envrc
contents or clone a project where a .envrc
is
already present, direnv will ask you to allow it. It is for security purpose:
you should always check what a .envrc
contents do. To allow it:
$ direnv allow
This is great, but you can however notice two things:
- the environment can take some time to build each time you run a shell in a project directory;
- the environment is not (yet) persistent: garbage collection would delete it from the Nix store.
In the direnv wiki page about Nix,
you can find a
script that
builds a persistent and cached shell, thus avoiding both the two remarks I
have written above. All you have to do to use it is adding it at the top of your
.envrc
files, like in this
one. This
script creates a .direnv
directory at your project root, containing symbolic
links to derivation outputs in the Nix store. It also links them in the GC
roots, thus avoiding the shell environment to be garbage-collected as long as
the .direnv
directory exists. For this to work as expected, you must tell Nix
to keep derivation outputs. Put this in your ~/.config/nix/nix.conf
:
keep-outputs = true
keep-derivations = true
If you are on a Mac, this script does not work out of the box: it indeed relies
on the GNU version of readlink
. To make it work, you can install coreutils
in your user profile via Nix:
$ nix-env -i coreutils
Please note that coreutils
replaces some of the standard shell commands. While
GNU versions are good, they may lack of some refinements like ls
being able to
show extended attributes. If you want to keep using your system ls
while
installing coreutils
, you can achive this by aliasing ls
to /usr/bin/ls
.
A few words about comfort
TL;DR I have written some aliases for
Nix
and
direnv.
You can also add a visual indicator to your prompt by checking if
$IN_NIX_SHELL
is
set.
As developers, we spend most of our day doing tasks on a computer. While some of these tasks can be long and require a great focusing, some of them like using tools as Git, Nix and direnv should be really quick and effortless. When in your shell, you shouldn’t have to ask if you are in a Git repository, on each branch, or wether you are in a Nix shell. Your shell should provide you information without you asking for. In the same way, interacting with these tools should be really quick. You shouldn’t end up typing things like:
$ git status
$ git checkout master
$ nix-shell --pure
These commands are not long, but they are too long to type—even with completion—for what they achieve. On my machines, I would do instead:
$ gcm
$ nisp
Note that I have omitted an alias for git status
. This is because my prompt
already shows me on which branch I
am,
and if there are changes or something to push or pull. Also, if I am in a Nix
shell, my prompt is blue instead of
green.
The purpose of this is not anymore about sharing a development environment with
others. It is all about personal comfort. The common base is made of full
commands: they can be run everywhere. You can always check the status of a
repository with git status
and run a pure Nix shell with nix-shell --pure
.
But on top of this common base, you should customise your environment so that
your computer really becomes an ergonomic tool. When I take photos I love to
think about my cameras like extensions of my arm, some of them being truly
ergonomic—hello Fuji GW690 II! When I am on a computer, I like to feel the same
way with my keyboard. Shortcuts and aliases, thanks to the muscular memory, are
really helpful to keep you in the flow.
This being said, if you want to get some inspiration, I have written some
aliases for
Nix
and
direnv.
The direnv ones even contains some
aliases
dedicated to the script for persistent cached shells. The idea is to help
cleaning old local environments by doing dcl
or at the end of a project,
cleaning the environment by doing dar && ngco
.
dar
is for “direnv archive”, that is, it denies the current .envrc
and deletes the
.direnv
directory. Running
ngco
then deletes the environment from the Nix store, as the .direnv
directory is
gone. This is all about personal taste, customise as you prefer.
Last but not least: starting to use Nix and direnv in an existing project is as
easy as running nixify
, which is defined
here.
It simply creates a .envrc
and a shell.nix
if they don’t exist.
Conclusion
Migrating from asdf to Nix has been an interesting journey. They both share some ideas when it comes to managing the build environment. However, Nix is several orders of magnitude more powerful than asdf.
If we only focus on Nix shells, Nix can not only manage the languages you use,
but also every other tool you would need for your development process. You can
use pure shells to check if your environment is complete, avoiding to use tools
available globally on your machine. Also, if a tool or version is not
available in nixpkgs
, you can easily write your own derivations.
When it comes to environment switching, asdf seems to be a winner in the first run, but once you have set up direnv you quickly forget about that.
Generally speaking, Nix is a more broader tool: you can use it to install software on your machine without messing your system, manage your configuration or automate your builds. If you are interested in Nix, you should also learn about these aspects.
I still have to figure about using Nix as part of my Rust workflow. I have
especially questions about the RLS setup and cargo commands like cargo watch
.
Once it is done, I may come with an article about it. I also plan to write a
much shorter article focused on Elixir projects—including Phoenix and Nerves—as
I have managed to build and run them in pure Nix shells.
I hope this article has been useful to you in your understanding of Nix. If something is not clear, please tell me in a response: I will update the article accordingly.
Edit: if you are an Elixir developer, you can read my article about Using Nix in Elixir projects.