why and how to use nix

This is my contribution to the “I started using Nix and here’s what I think” genre of blog posts, which you can find many examples of online. I think the world of software would be better if more people learned about and used Nix, so I’d like to offer some suggestions to help get people into the Nix world. This will be opinionated. There might be bad ideas, or I might just be wrong about something. If you disagree, please let me know.

This post assumes at least some experience with programming and some experience with Linux.

Why ¶

Let me say it upfront: Nix is a brilliant invention, a magical piece of computer science and engineering that solves so many problems in building and deploying software in what is so obviously the Right Way, that I’m sad I didn’t discover it years ago. (And in bolder moments, I’m sad I didn’t invent it myself.)

But Nix is quirky and hard to learn, so it doesn’t get the appreciation that it deserves. I think this is because people approach it the wrong way and try to apply a mental model that doesn’t fit. I think starting with the right one will make it easier to understand.

Nix is not a “package manager”; Nix is a build system. Or really, a meta-build-system. Nix is much more like Bazel than it is like apt or pacman.1

Nix builds and links large software components together similarly to how a compiler builds source code into object files and a linker links them into an executable. Nix can be used to build and “link” a few packages, or something as big as a whole operating system, but the key concept is software components linked together.

The magical part is that it does it in a way that’s analogous to static linking rather than dynamic linking: each component of the system has all of its dependencies resolved statically. This means that they can’t be broken later by changing dependencies, similarly to how a statically-linked binary can’t break due to missing or changed libraries once it’s built.2 Note that it does this while retaining some of the benefits of dynamic linking (e.g. sharing disk and memory).

This means that if a component changes, then all components that depend on it need to be rebuilt. How does Nix know what to rebuild? As a meta-build-system, doing sandboxed builds,3 Nix is aware of all the inputs that go into building a component, including the compiler, build options, and even the build tools themselves. Here’s the clever part: Each component output is identified by a hash of all of its inputs,4 and that’s where it’s stored on disk. A different version of any input would produce a different hash. Nix would see that the output named by that hash is missing, so it has to be built.

Nix has been used to build and link together an entire Linux distribution: NixOS. The incredible thing is that the entire OS is defined by Nix code (plus some scripts to glue it together), and it lives in a single repo that’s easy to navigate and modify. It’s incredibly transparent and malleable compared to other distributions.

NixOS effectively rebuilds the entire OS (including config files) each time you change the configuration, and each “OS” is statically linked in the way described above, so once it’s built, nothing can break it. (To make this practical, many components are fetched from a public binary cache, but only if they’re an exact match for the build instructions.)

This model enables true declarative configuration from the ground up, which is much more reliable than configuration management tools that try to impose a configuration from the top down, which will always fail to capture some state. (To be fair, a NixOS installation has some non-declarative state, but much less that other distributions, and with some effort you can eliminate most of that.)

This is also where Nix’s impressive system rollback feature comes from: Each “OS” lives independently on disk (sharing where possible) and you can choose to boot into an older one.

At this point you might ask, but doesn’t Docker do most of that? There’s a lot of overlap between Nix and Docker: they can both be used to build and deploy “essentially-statically-linked” software artifacts, but Nix is better at it:

When you develop with Docker, you run containers, with a little OS (userland, not kernel) inside them. The inside and outside are different, with different software installed and files in different places. This creates a lot of friction when you want to run debugging tools in a container or even examine files installed in a container. With Nix, you can use containers, but you can also not use containers: software you build and run with Nix can integrate seamlessly into your OS, you don’t have to worry about inside and outside a container, and you get all that without giving up any of the good parts of Docker, like being able to deploy the exact same image you tested in development.

Nix shares binaries at a finer level of granularity: with Docker you share whole layers, but only the common prefix of the layer stack. Nix lets you share any common subtrees of the dependency tree. If the build environment you’ve specified is substantially similar to your OS, many core libraries will be shared between them. (If it’s not, you’ll end up with more copies of things, which is about the same as you’d get with Docker.)

Of course, Nix can be used to build Docker images if you have a deployment system that expects them, and the result is more reliably reproducible than Dockerfiles.

What ¶

One common point of confusion is the relationship between the various pieces of the Nix system:

Nix: the meta-build-system described above.

the Nix language: the language used by Nix to describe meta-builds. It can also be used as a general JSON-like configuration language. Nix is a purely-functional lazy language, which can be a little hard to get your head around if you’re new to functional programming.

Nixpkgs: build specifications (in the Nix language) for many thousands of pieces of software, mostly free and open-source, but some non-free also.

NixOS: a set of scripts to arrange packages from Nixpkgs into a usable Linux distribution, plus a system for generating system config files from Nix language configuration.

Note that NixOS and Nixpkgs live in the same Git repo! Nix-the-meta-build-tool itself lives in a separate one.

In addition to those core pieces, there are some unofficial projects that bring Nix to other places:

home-manager: like NixOS, but for your home directory: instead of generating system config files, home-manager generates personal config files (i.e. dotfiles) and manages a personal set of installed components distinct from the system’s set.

nix-darwin: like NixOS, but on top of MacOS.

And a few more terms you’re likely to see:

NixOS is built out of modules: pieces of Nix code that configure some part of the OS, and sometimes depend on packages from Nixpkgs. The module system is quite flexible and supports things like priorities, type-checking, and built-in documentation. Higher level modules might describe a service configuration declaratively, while lower level modules generate config files to go in specific locations. To configure or customize NixOS, you write a module yourself, which can explicitly import other modules and set attributes that they implicitly depend on.

Nixpkgs, as the name indicates, is built out of packages, which are build instructions for software components that can depend on other packages and sometimes on configuration. The build instructions are called derivations.

Nixpkgs doesn’t use modules, and the ways to customize packages in it are a little ad-hoc: sometimes you can pass package-specific configuration to packages, but the most general way to customize Nixpkgs is overlays, which are essentially Nix code that can add or modify packages in the package set.

There’s an new feature of Nix called flakes. Do not use flakes! Yes, flakes solve some problems and make it easier to make things reproducible. But flakes are an experimental feature, not well documented, and somewhat more complex than “classic” Nix. Learn the classic system first before trying to use flakes.

Channels are confusing because the word is used to refer to several related but different things:

Primarily, a channel is a release branch of Nixpkgs. If you’re using NixOS, then the channel you want to follow is named something like nixos-yy.mm. (Do not follow release-yy.mm! That’s the “target branch” that desired commits are merged to. Once they pass CI, the nixos-yy.mm branch is advanced to the latest tested commit.)

Also, there’s a tool called nix-channel that is used to download snapshots of Nixpkgs, but can also be used to download other repos or even Nix code that didn’t come from a git repo. The bundles of Nix code managed by nix-channel have local names, which may or may not match the branch name that the code came from. I think nix-channel is more confusing than it’s worth, and suggest an alternative below.

How ¶

Hopefully you’re convinced that this thing is worth trying. How to get started? Here’s where the strong opinions come in. There are often many (maybe too many) different ways to do something in the Nix world, with various trade-offs among transparency, reproducibility, declarative-vs-imperative-ness, and other axes.

This is a description of a path that worked for me, and I think is minimally confusing and avoids various misfeatures.

You can use Nix-the-meta-build-system and Nixpkgs on other Linux distributions or other platforms entirely, but I’d suggest just diving in and installing NixOS. It can easily dual-boot with other Linux distros or Windows.

There are a few reasons:

  • You’ll get more exposure to the Nix language and get more comfortable with it faster.
  • You’ll be able to do declarative package management without home-manager.
  • Nix is just better-integrated into NixOS than into other distributions (unsurprisingly). You won’t have a whole other copy of a base system, you get systemd services configured for you, builds are automatically sandboxed, etc.

The official documentation for installation is pretty thorough and I don’t have much to add, so look there. With some careful repartitioning, I was able to add NixOS to two Ubuntu machines by installing Nix in Ubuntu and installing NixOS from there (Section 2.5.4 in the manual). This seemed easier than using a USB boot drive.

One thing that’s mentioned briefly in the docs but should be highlighted, especially if you’re using a laptop, is the nixos-hardware repo, which has pre-made configuration profiles for many models of hardware.

Now you have a basic NixOS system with one primary user account, and you want to do something simple like “install a package”. You have a lot of choices! You can:

  1. install it declaratively, system-wide, in environment.systemPackages
  2. install it declaratively, for one user, in users.users.<user>.packages
  3. install it imperatively in your user profile with nix-env -i
  4. install it declaratively with home-manager using a stand-alone configuration
  5. install it declaratively with home-manager as a module
  6. install it declaratively in a development environment with shell.nix or default.nix
  7. install it in a single shell with nix-shell -p

These all have pros and cons and may be appropriate in different situations, but to simplify things, we’re going to narrow it down to (1) and (6). Why those? First, a major strength of Nix is doing things declaratively, so let’s drop (3). Next, home-manager may be awesome, but learning Nix and NixOS is already a lot, and throwing home-manager into the mix before getting those down will confuse things, so drop (4) and (5).5 Finally, on a single-user system, there’s little practical difference between (1) and (2), so let’s just pick (1).

The decision is now simpler: if the package is something you’re going to use in general computing, e.g. desktop applications, editors, system administration tools, put it in environment.systemPackages, and if it’s project-specific (e.g. language runtimes, compilers, libraries), put it in default.nix or shell.nix. You can always do (7) and try out packages with one-off nix-shell invocations too.

Note one of the great features of Nix: you can “install” packages in development environments (or user profiles) and they behave just like system package, because they are exactly the same thing. If your development environment happens to call for the same version of a package that’s on your “system”, you’ll even share the very same files. If it’s different, you’ll just get a different installed version of that package.

So you’re installing a package. Which version will you get? That depends on a lot of things:

  1. For a system-wide package, it depends what version root’s nixos or nixpkgs channel is set to.
  2. For a development environment (or user profile) package, it depends on your user’s channels, but will fall back to root’s channels if your user doesn’t have channels set.
  3. Or maybe your project-specific default.nix pins a specific version of nixpkgs (with an explicit hash, or using niv).
  4. Or maybe you’ve customized NIX_PATH so it’s not using channels at all! You can customize NIX_PATH a) system-wide in your NixOS configuration, b) in the regular environment before running Nix commands, or c) on the command line with the -I flag to many Nix commands.

This is really confusing! For example, both root and your user account have independent sets of channels. In some contexts (multi-user systems) that’s a powerful ability. For a single-user machine, it’s unnecessary complexity. You might get different results running commands with sudo vs. sudo -i (which resets the full environment).

Next, how do you know what specific version of nixpkgs (or other source) a channel is pointing to, or what version you’ll get when you upgrade the channel? There’s not a general answer to those questions.

For those reasons, I recommend not using channels at all (this is probably my most unorthodox suggestion). The nix-channel mechanism is just a fancy way to get a copy of nixpkgs on your disk. nixpkgs is a git repo. What’s the usual way to get a git repo on your disk? Clone it!

Here’s the idea: Clone nixpkgs to somewhere on your machine. Check out the channel branch for your NixOS release, currently nixos-20.09. Set NIX_PATH system-wide with a configuration like this:

  nix.nixPath = [
    # any other repos you want to use in your system configuration

(Don’t forget to apply the configuration and log in again so all your shells pick up the new environment variable.)

This means that any reference to <nixpkgs> will use your local clone of Nixpkgs. To avoid confusion, you should then delete all your user and root’s channels with nix-channel --remove (and sudo -i nix-channel --remove for root).

Whenever you want to update your system, git pull the latest nixos-20.09 and rebuild. You could also make a new branch, e.g. current-sys and have it track nixos-20.09 if you don’t want to “take over” the official branch.

Why do this crazy thing?

  • If you’re using Nix seriously, you’re going to need a local nixpkgs clone anyway: You’ll use it to make PRs for updating packages and other contributions to nixpkgs. You’ll also use it to read NixOS code to figure out what various configuration settings do (the docs are okay but there’s no substitute for digging in, and it’s pretty easy). And you’ll read Nixpkgs code to figure out how to write derivations. Since you’re going to have a checkout anyway and keep it up to date, you might as well use it instead of nix-channel.
  • Everyone knows how to use git already. Not having to bother with nix-channel is one fewer thing to learn.
  • You can use standard git log commands to see exactly what changed between updates, if you’re curious.
  • It makes it really easy to hack on NixOS and Nixpkgs: just make some changes and run nix-build or sudo nixos-rebuild. No need to remember various -I flags. git pull --rebase to keep your local changes on top of the branch.
  • Pulling the latest changes with git might be faster and use less disk space than pulling a whole new copy of the repo with nix-channel.6

Some downsides of doing it this way:

  • Nix will use whatever the current state of the working tree is, so if you’ve been switching branches (e.g. to master) you have to make sure to switch back to your nixos-... branch before you run nixos-rebuild. I wrote a small wrapper script that checks that I’m on the right branch to avoid this mistake. Another approach would be to keep two clones, one for hacking and one for the current system (clone one from the other so they can share the 1+GB initial pack file).
  • It feels weird to have system-level things depend on files in a user’s directory. Of course, this is for a single-user machine. For a multi-user machine, or if it’s just too weird for you, you can have root do the git checkout instead.

This method is somewhat imperative, in the same way that nix-channel is: there’s some current “state” and you update it by running a command. Shouldn’t we try to avoid imperative methods? Yes, but this one isn’t too bad: the state is completely transparent and easily inspectable and modifiable using tools you already know (git). Also, switching to a new configuration is necessarily imperative, and you’d typically only update before doing that.

For development environments, though, the imperative approach won’t do. You don’t want the results of building your project to depend on anything outside of the project directory, like a Nixpkgs clone or some nix-channel state. If it did, then building the same code on two different machines could produce different results, which would make it hard to collaborate with other developers or even switch between different machines yourself.

The great thing about Nix is that it lets you achieve this (a fully hermetic build) using the same mechanism you use for configuring the OS. It works for any language, for multi-language projects, and for any type of dependency, e.g. database servers, not just libraries.

Development environments and Nix-based builds start from a default.nix in your project directory, and likely include compilers, runtimes, and other tools from Nixpkgs. To make this independent of whatever is happening with the rest of your system, you’ll want to pin the version of Nixpkgs. There’s a few approaches for doing that in these articles:

Personally, I’ve been starting projects from a very simple template that looks like:

# default.nix
{ pkgs ? import (fetchTarball {
    url = "https://github.com/NixOS/nixpkgs/archive/d4189f68fdbe0b14b7637447cb0efde2711f4abf.tar.gz";
    sha256 = "1k9x7z4a8xsmcywwpm7jbgnzrrg0b97ygwjk2adc4jhk9c0ljdny"; # nixos-20.09 @ 2021-02-22
  }) {} }:

Bumping the Nixpkgs version this way is slightly cumbersome (though you probably won’t do it that often). If you’d like something fancier, check out niv.

What else goes in default.nix? If you’re ready to write a full Nix derivation for your build, great. If not, you can write a dummy derivation and just list some dependencies in buildInputs and they’ll automatically be set up for you when you run nix-shell.

Instead of manually running nix-shell, though, you should use direnv. Although not a Nix-specific tool, it works very smoothly with Nix to “activate” development environments when you enter a project directory in your shell. Just put the line use nix in a file named .envrc and run direnv allow: it will call nix-shell, grab all the new environment variables, and insert them into your current shell environment (then remove them when you cd out of your project).

(Note: there are multiple solutions for connecting direnv and Nix, with various tradeoffs. If the built-in integration isn’t working for you, read about the others and pick one.)

That’s Nixpkgs. What about your system configuration?

You should put your system configuration(s) in a git repo, e.g. ~/src/my-nixos-configs, for all the same reasons you put other code and configurations in version control: keeping history and context, synchronizing changes across machines, sharing with others. To avoid having to run git as root (which might make it fail to find the right .gitconfig or ssh-agent), just keep your configuration in your user’s home directory and edit and use git commands as your user. (Remember, this is for single-user systems.)

But nixos-rebuild will still look in /etc/nixos, right? Well, you might have noticed this line in the snippet above:

  nix.nixPath = [

Putting that in your configuration.nix means that nixos-rebuild will look at that file for the system configuration. Then you can delete /etc/nixos to avoid confusion.

I put some sample configs and code based on these suggestions here: github.com/dnr/sample-nix-code. It’s always more educational to start from scratch, but sometimes it’s nice to have a more complete example also.

By the time you’ve installed and configured NixOS once or twice and set up a couple of development environments, you probably know enough Nix to have your own opinions about how to do things, that might disagree with mine.

There’s lots of Nix resources out there if you get stuck:

I plan to write some more posts about how I solved various problems with Nix. In the meantime, start building stuff!

  1. You might be worried because Bazel also has a reputation for being hard to learn. Don’t worry: I found Nix much easier than Bazel. ↩︎

  2. The exact details of how this “linking” happens depends on the nature of the dependency. For ELF shared objects, the absolute path to a particular library will be embedded in the binary (so the dynamic linker will be involved at runtime, but it will always find the same thing). For programs run as a subprocess, or data files, sometimes programs will be patched to use absolute paths, or sometimes wrappers will be created to set environment variables. The details can be messy, and you can find them all in Nixpkgs. ↩︎

  3. i.e. the build process is not allowed to access anything that hasn’t been declared as a dependency. Nix also goes to some lengths to try to make builds as deterministic as possible. This isn’t necessary to achieve most of Nix’s desirable properties, but it adds confidence. ↩︎

  4. Actually, not the inputs themselves, but the hash identifiers of those inputs. This way, Nix can compute the hash for any component based on a full tree of hashes before it even builds any of them. (Usually.) ↩︎

  5. Once you’ve got the hang of things, feel free to try home-manager if you want. I haven’t used it yet because I built my own dotfile manager years ago. I’ll probably migrate things to home-manager some day. ↩︎

  6. “Might” because git is doing more computation even though less data is transferred. They’re both fast enough and this is admittedly a minor point. ↩︎