An Introduction to Nix
Hopefully by the end of this post you’ll have a better idea of what Nix is, why it might be useful, and how to start playing with it. I’m not an expert Nix user, but I’ve tried to write the introduction I wish I’d been given.
Power-to-weight ratio
Nix is a cross-platform package manager, and I was drawn in by its flexibility. I love tools with a high ‘power-to-weight ratio’: asdf, for example, is a version manager like nvm or rbenv but with plugins for a huge number of languages. With one set of commands to learn, you’re equipped to manage the version of almost any language you might come across. There’s much more to learn with Nix, but it’s correspondingly more powerful and you don’t need to learn it all at once. To name a few of its offerings: reproducible development environments, system configuration, language-agnostic build-tool, continuous integration environment, deployment machine configuration, upgrade rollbacks, and package isolation (sandboxing). I’ll be focusing on the first two here to give a flavour of what’s possible with Nix.
nix-shell
With Nix, you can add one file to a project written in any language(s) to define
the dependencies needed to work on it. A Go project relying on just
to run project-specific commands? No problem, add one file to the project and
anyone with Nix installed can spawn a shell with Go and just
installed in a
single nix-shell
command. The file would be called shell.nix
and it would
look something like this:
let
pkgs = import <nixpkgs> {};
in
pkgs.mkShell {
buildInputs = [
pkgs.go
pkgs.just
];
}
The above is a simple example and would result in prebuilt binaries for the two
packages being downloaded, but if you needed an ancient or modified version of
Go or just
, you could declare versions/modifications with Nix and they
would be built on demand. And if you later started onboarding collaborators and
received complaints about build times, you could cacheI’ve come across two types of caching for nix-shell
. There’s a
straightforward build it slowly once and run instantly thereafter
caching with cached-nix-shell (only available on Linux), and there’s
build it slowly once on your machine and push the output onto the
internet to save other people time caching with cachix (further
reading). The former can be used for nifty inline nix-shell shebang
lines! the build output to
save them building the dependencies on their machines.
What’s happening under the hood?
A key feature of Nix is that it’s purely functional. In other words, it makes
its changes in a very self-contained way. This enables features like rollbacks
which would be much more complicated if it littered files all over your system.
All packages installed by Nix live in the Nix store, which is usually the
/nix/store
directory. To avoid collisions, packages get installed in folders
with names like /nix/store/b6gvzjyb2pg0kjfwrjmg1vfhh54ad73z-firefox-33.1/
which contain a hash of package’s dependencies, and symlinks are created in
paths like ~/.nix-profile/bin
.
In the case of a rollback, Nix should still have the previous package version (if it hasn’t garbage collected) so all it needs to do is adjust the symlink in your path to point to the previous version. Another consequence is that upgrading (or uninstalling) one package won’t affect your other packages. This is because upgrading a package really installs the new version of the package into a new, separate directory in the Nix store and updates the symlinks to point to it. This means the old version can stick around undisturbed for other packages which depend on it. In practice, this also means you don’t need to worry about one package install or upgrade initiating a chain upgrades for other packages that you weren’t expecting (homebrew users might know what I’m talking about…)
For a comprehensive, structured introduction to how Nix works (and how to use it), I recommend Nix Pills and the shorter How Nix works page on the NixOS website.
How to get started
Installing Nix is pretty easy. It’s been a little bit fiddly on macOS in the
past but I think things are in a good place at the time of writing.And the future looks bright with opencollective funding for “making macOS
first-class citizen on Nix”! The NixOS
website’s install instructions list a one-line command with the --daemon
flag. If you’re installing Nix on macOS, you may need to add the
--darwin-use-unencrypted-nix-store-volume
flag too.It looks like you might not need to specify any flags after this PR
gets merged, which will disable single-user installs (the current
--daemon
flag initiates a multi-user install) and do the right thing™
with filevault encryption (which has been a pain-point in the past). It’s
also worth noting that the current Nix install process can cause trouble
when upgrading to macOS Big Sur but that there is a script you can run
post-install to fix the issue.
How I use Nix (nix-darwin, home-manager)
Although I’m experimenting with nix-build
-ing this website, I mainly use Nix
system configuration. There are two tools I use for this, nix-darwin
(macOS-specific) and home-manager (OS-agnostic). In short, I use nix-darwin
for macOS-specific configuration (like homebrew casks), and I use home-manager
for everything else (mostly specifying packages I want installed and putting
configuration files in the right place). If you’re just starting out with Nix,
I’d recommended setting up home-manager on its own at first to keep things simple.
The installation instructions are reasonably simple,Don’t be put off by the “words of warning” in the home-manager Readme. If
you build up your config slowly, you’ll be fine! although it helps
to know that you can check which nixpkgs channel you’re following with
nix-channel --list | grep nixpkgs
. Once you’ve finished installing
home-manager you should be able to find an initial configuration file in
~/.config/nixpkgs/home.nix
generated by the installer.
{ config, pkgs, ... }:
{
programs.home-manager.enable = true;
home.username = "$USER";
home.homeDirectory = "$HOME";
home.stateVersion = "21.05";
home.packages = [
pkgs.ripgrep
];
}
Your home.nix
will have some explanatory comments, but hopefully looks
something like the code block above. To install new system packages, using
ripgrep as an example, you can search on the web at search.nixos.org
or at the command line with nix search ripgrep
, add it to a home.packages
list in your home.nix
file (as shown above) and run home-manager switch
to
apply your changes. To install ripgrep outside of home-manager you would run
nix-env -iA nixpkgs.ripgrep
and to spawn a temporary shell with ripgrep
installed you could run nix-shell -p exa
.
Before diving into package configuration, I’ll quickly explain a couple of tricks to make nix files a little neater. The above code block, for example, can also be written like this:
{ config, pkgs, ... }:
{
programs.home-manager.enable = true;
home = {
username = "$USER";
homeDirectory = "$HOME";
stateVersion = "21.05";
packages = with pkgs; [
ripgrep
];
};
}
There are two changes here: first, the curly bracket syntax used to group all
home
settings together without needing to write home
on each line and
second, the with pkgs;
declaration before the package list which brings in
pkgs
scope for that expression (so you don’t need to write it out for every
package in the list). While we’re here, you might also be wondering what that
first line with { config, pkgs, ... }:
means. Simply enough, it indicates that
this file expects an attribute set (like an object in JavaScript or a dictionary
in Python) as an argument, and it destructures attributes from it like you would
variables from an object in JavaScript. The attribute set argument is provided
by home-manager when you run home-manager switch
.
Beyond installing packages, you might want to try using home-manager to configure your development tools. To demonstrate, here’s (the home-manager portion) of my zsh configuration:
programs.zsh = {
enable = true;
enableAutosuggestions = true;
plugins = [{
name = "zsh-history-substring-search";
file = "zsh-history-substring-search.zsh";
src = pkgs.fetchFromGitHub {
owner = "zsh-users";
repo = "zsh-history-substring-search";
rev = "v1.0.2";
sha256 = "0y8va5kc2ram38hbk2cibkk64ffrabfv1sh4xm7pjspsba9n5p1y";
};
}];
sessionVariables = { ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE = "underline"; };
initExtra = ''
alias ls='echo; ${pkgs.exa}/bin/exa'
bindkey '^[[A' history-substring-search-up
bindkey '^[[B' history-substring-search-down
[[ -e $HOME/.asdf/asdf.sh ]] && . $HOME/.asdf/asdf.sh
${builtins.readFile dotfiles/dot-zshrc}
'';
};
When writing configuration for a program in home-manager, the primary resource to discover your options is home-manager’s options documentation. If you want to read more about any of the options I’ve used above, or see what other options exist, it’s worth having a look. You’ll notice each option also has a link in its ‘declared by’ section which takes you through to GitHub and should give you an idea of how the option works behind the scenes.
The zsh section of the options documentation lists an assortment of
(perhaps familiar) zsh plugins like oh-my-zsh in addition to native zsh features
like history deduplication. My approach has been to retain my pre-existing
.zshrc
configuration file (for shareability and portability) but replace the
complexity of a plugin managerI explored various plugin managers (notably antibody) landing on a
wonderfully zippy but not-so-readable zinit-based config… with straightforward home-manager
options. Some plugins like autosuggestions are available as home-manager
options, so enabling them is as simple as enableAutosuggestions = true;
while
others need to be added to a plugins array. The plugins array is easy enough to
construct, but it isn’t immediately obvious where to get the sha256
value. For
the lazy, it seems the thing to do is set it to something obviously wrong and
correct it after running home-manager switch
to see the failing comparison
in your terminal.
In an effort to keep things tidy, I try to configure plugins near where they are
installed. I set plugin-related environment variables in sessionVariables
and plugin-related keybindings in initExtra
which is the place to put any
other config you want to land in your .zshrc
. I load the rest of my zsh config
at the bottom of initExtra
with ${builtins.readFile dotfiles/dot-zshrc}
which pulls in the contents of the file at that relative path. This allows me to
keep a portable .zshrc
file which I can share with others. Although all
home-manager zsh options ultimately manifest in the ~/.zshrc
file that it
writes (another symlink to the Nix store),Files in the Nix store are read-only so any configuration files ‘owned’ by
home-manager (~/.zshrc
in this case) can only be changed by editing your
home-manager config and running home-manager switch
. It looks like there
are ways around
this,
though. the file it produces is pretty ugly
so it’s nice to have my tidy self-contained .zshrc
file from before.
The one other line where I use string interpolation is for my ls
alias which
adds a blank line with echo;
and then runs exa. By specifying
${pkgs.exa}/bin/exa
in the alias, the line that ends up in ~/.zshrc
looks
like this:
alias ls='echo; /nix/store/lm2yj1mmhczp73s52kwxdsrw1ks1z5wy-exa-0.9.0/bin/exa'
Nix evaluates ${pkgs.exa}
, installing it if necessary, and interpolates
the full path to exa
in my Nix store. That way, I don’t need exa
in my
$PATH
and that means one less entry in my package list, which is nice.
Finally, the remaining detail in my zsh config is the line which loads the asdf
version manager mentioned at the start of this post. You might think that Nix,
with its capacity to retain multiple versions of the same program side-by-side,
would solve the version-manager problem but the story is disappointingly
lackluster right now. Maybe one day they’ll improve the ergonomics for that
use-case, but the discussion in the related GitHub issues doesn’t inspire
optimism. Until then, I’ll keep that line in my home.nix
hoping to someday remove it.