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.