Alex Fedoseev
2022, 16 Feb

Nix Time

Recently, I received my new MacBook Pro with M1 chip, and it was a good time to tackle a long-standing to-do: try out the Nix package manager.

It's not a deep-dive style post (I'm not qualified), but a quick summary of why and how I use Nix in my day-to-day activity.

Why

I'll start with what I want from a package manager:

  • Management of globally installed packages.
    Basically, what Homebrew does.
  • Isolated environments.
    Some projects have global dependencies that might mess up the operating system or other projects. Or they might require a specific version of a package. Developing apps in Docker is not an option because it's slow and awkward.
  • Installation of specific versions of packages.
    Not all projects run on the latests.

With some degree of success, Nix addresses all of these points.

Installation

At the moment of writing, I had to run a single command, and it went smoothly:

sh
sh <(curl -L https://nixos.org/nix/install)

Check the official docs since this post might be dated at the moment of reading.

Usage

Nix uses a store—disk partition on macOS to store its stuff, such as packages, generations, etc. It is immutable. The state of the system is called generation. If greatly simplified, it describes how to symlink packages from the store to your system. Changing something in your system via Nix creates a new generation instead of mutating the previous one. Thus, if you messed something up, you can roll back to the previous state. With that in mind, let's proceed to the practical parts.

Caution: I don’t use flakes or any other unstable Nix feature. I have enough bleeding edge in my life. Also, I don’t use profiles (explicitly). As far as I understand, if I haven’t touched this feature, Nix implicitly uses a single default profile. It works for me.

Managing global packages

There are two ways you can manage globally installed packages with Nix:

  1. nix-env: imperative.
  2. Home Manager: declarative.

nix-env

nix-env is built-in. It is similar to how you handle packages with the brew. You run one command to install packages. You run another command to update or uninstall packages.

sh
# checking if the `tree` package is available
nix-env -qaP tree

# installing `tree`
nix-env -i tree

# updating `tree`
nix-env -u tree

# uninstalling `tree`
nix-env -e tree

Nix uses channels as a source of packages. The official registry is Nixpkgs. You can use its branches as channels. By default, it is nixpkgs-unstable. And this is what I'm using.

Home Manager

Another option is the Home Manager. One of its features is package management via the configuration file. To install a package, you need to add it to the config:

nix
home.packages = [
  pkgs.htop
  pkgs.tree
];

And run the command:

sh
home-manager switch

To uninstall a package, remove it from the config, and run the same command. Don't forget to switch after changing the config.

Home Manager is not only about managing installed packages. This tool allows handling your dotfiles, too.

For example, you can configure your shell:

nix
programs.zsh = {
  enable = true;

  autocd = true;

  sessionVariables = {
    EDITOR = "nvim";
  };

  shellAliases = {
    g = "git ";
    v = "nvim ";
  };

  initExtra = ''
    source "$HOME/.cargo/env"
  '';
};

Or Git:

nix
programs.git = {
  enable = true;

  userName = "John Doe";
  userEmail = "john@doe.mx";

  extraConfig = {
    core = {
      editor = "nvim";
      ignorecase = false;
    };
  };
};

If you need to apply some dotfiles that Home Manager doesn't handle, you can use a raw file:

nix
home.file = {
  ".editorconfig".source = ./path/to/.editorconfig;
};

It will symlink such files to your ~.

Check out its documentation for all the options. There are many. Also, here are my dotfiles with a HomeManager-based setup.

There’s another third-party tool called nix-darwin for managing an operating system via Nix. While Home Manager is for your home directory, nix-darwin is for your macOS. Home Manager can be integrated with it. Personally, I don’t use it. But letting you know here that it exists.

Isolated environments

One of my primary motivators to learn Nix is the ability to configure the shell on a per-project basis without polluting global space. For example, if a project needs a specific C compiler, I wouldn’t need to install it globally. Instead, I would ask Nix to make it available within an isolated shell only for this specific project. And it would prepare such a shell for me. Also, it can replace tools such as fnm, nvm, rvm, rbenv, etc., since it’s possible to install a specific package version within this isolated environment. It’s a bit awkward, though. I will elaborate on it a bit later in this post.

So, how my workflow looks from a usage perspective:

  • I cd into a project directory.
  • *magic*
  • My specially crafted shell is ready to use.

Yeah, it’s that simple. Let's see how to configure it.

Even though you can use a nix-shell command to enter a Nix shell, I prefer autoloading it on cd. direnv helps with it.

First, enable it in the Home Manager configuration:

nix
programs.direnv = {
  enable = true;
  nix-direnv.enable = true; # this is optional, see https://github.com/nix-community/nix-direnv
  enableZshIntegration = true;
};

If you manage your shell with Home Manager, you're good to go. Otherwise, check the direnv docs on hooking it into your shell.

Next, configure your project.

Place a file called shell.nix at the project's root directory:

shell.nixnix
with (import <nixpkgs> {});

mkShell {
  buildInputs = [
    ruby
    nodejs
  ];
}

This file is written in the Nix language. Check out the nix pills for the basics.

When it’s done, you can test it by running a nix-shell command, which should take you into the shell, where all these packages are available. By default, it uses bash, but direnv is smart enough to take you to your shell of choice.

To let direnv know that it should load Nix shell here, create the following .envrc file in the root of the project:

.envrcsh
use nix

And run the command (you need to do this only once):

sh
direnv allow .

Done. Now, whenever you cd here, shell with ruby and node stuff will be loaded for you.

Installing a specific version of a package

The good news is that it is possible with Nix. The bad news is that it's a bit awkward.

First, check if a specific version is available under the corresponding name. For example, Node.js has the following packages:

$ nix-env -qaP nodejs

nixpkgs.nodejs-10_x  nodejs-10.24.1
nixpkgs.nodejs-12_x  nodejs-12.22.9
nixpkgs.nodejs-14_x  nodejs-14.18.3
nixpkgs.nodejs       nodejs-16.13.2
nixpkgs.nodejs-16_x  nodejs-16.13.2
nixpkgs.nodejs-17_x  nodejs-17.4.0

If v12.22.9 is what you need, use nodejs-12_x as a package identifier. Otherwise, you have to fetch it from the git history of the Nixpkgs repo.

To simplify this process, Marcelo Lazaroni created this tool. Let's say you need Node v12.22.7. Go to the URL and search for a nodejs package. In the results table, find 12.22.7, and click on the commit SHA to see the instructions.

Using the code from the instructions, shell.nix would look like this:

shell.nixnix
with (import <nixpkgs> {});

let
  pkgs = import (builtins.fetchTarball {
    url = "https://github.com/NixOS/nixpkgs/archive/5e15d5da4abb74f0dd76967044735c70e94c5af1.tar.gz";
  }) {};

  nodejs-12_22_7 = pkgs.nodejs-12_x;
in

mkShell {
  buildInputs = [
    nodejs-12_22_7
  ];
}

If the version doesn’t exist, you can find build instructions for the closest version in the Nixpkgs repo and modify it locally to build it in your shell. I never did that, but it should work.

Tips and caveats

Cleaning

Since the Nix store is immutable, it might grow over time. So it makes sense to clean things up from time to time.

sh
nix-env --delete-generations old
nix-store --gc

Shell hooks

In shell.nix, you can define shell hooks. Useful to export environment variables or execute scripts. Unfortunately, aliases wouldn't work in zsh.

nix
mkShell {
  shellHook =''
    export FOO="bar"
  '' ;
}

Bundles

I don't think it's called bundles, but anyway. Besides top-level packages, Nixpkgs has additional top-level entries that bundle together a set of packages. For example, OCaml tools or Ruby gems. I use two of them at the moment: ocamlPackages and vimPlugins.

You search for a specific package using the following command:

sh
nix-env -f '<nixpkgs>' -qaP -A vimPlugins | rg nerdtree

It's useful to explore the Nixpkgs repository directly to figure out what bundles are available and at which paths. Some pointers:

Don't use GitHub search. It's unreliable. Pull the repo and execute a search against your channel's branch.

Example of my shell.nix for PPX development:

shell.nixnix
with import <nixpkgs> {};
with pkgs.ocaml-ng.ocamlPackages_4_12;

mkShell {
  buildInputs = [
    ocaml
    dune_2
    findlib
    ppxlib
    reason
    merlin
  ];
}

Overrides

If you find that some dependency constraints don't meet, you can override dependencies of dependencies. For example, if your project has the following package.json, running yarn would fail due to nixpkgs's yarn having nodejs dependency, which is v16_x at the moment of writing, and which is used in the context of yarn.

package.jsonjson
"engines": {
  "node": "14",
  "yarn": "1.22"
}
shell.nixnix
with (import <nixpkgs> {});

mkShell {
  buildInputs = [
    nodejs-14_x
    yarn # not gonna work
  ];
}

To fix it, you need to override nodejs of this package:

shell.nixnix
with (import <nixpkgs> {});

mkShell {
  buildInputs = [
    nodejs-14_x
    (yarn.override { nodejs = nodejs-14_x; })
  ];
}

Apple frameworks

Some dependencies might fail to build due to the following (or similar) error:

error: linking with `cc` failed: exit status: 1
= note: ld: framework not found Security
        clang-11: error: linker command failed with exit code 1 (use -v to see invocation)

It means that they rely on the Apple framework, which is unavailable. To fix this, add darwin.apple_sdk.frameworks.<MISSING_FRAMEWORK> to the shell.nix.

shell.nixnix
with (import <nixpkgs> {});

mkShell {
  buildInputs = [
    openssl
    darwin.apple_sdk.frameworks.Security
  ];
}

nixpkgs-unstable

If you rely on this channel, you get frequent updates. That's the point of using it. But as far as I understand, your unpinned dependencies might change their versions unexpectedly since no lock files exist (flakes should address that). Something to keep in mind.

Shell aliases

I find myself using those most of the time:

nix
# applies Home Manager config
xx = "home-manager switch";

# searches for a top-level package
xs = "nix-env --query --available --attr-path ";

# searches within a specific bundle
xsp = "nix-env --file '<nixpkgs>' --query --available --attr-path -A ";

# updates the channel
xup = "nix-channel --update";

# cleans up the store
xc = "nix-env --delete-generations old && nix-store --gc";

I'll try to add more notes here as I go.