Posted on

Getting started with Nix

I have been using Nix for more than 6 months now and I have gradually migrated 5 boxes to it. I'm by no means a Nix expert, but at this point I feel like I can write down my experience for others that are just starting out. Before it becomes second nature and I forget all the pain I went through to get here. There is never enough getting started tutorials!

Note: I'm using Flakes exclusively, they are listed as an experimental tech, but are sufficiently stable and well supported.

Who is this for

I think Nix is great, however it will be a lot easier to learn if you have some Functional Programming chops. I'm writing this tutorial for developers, as they can get the biggest lift out of Nix. For others, should you learn Nix? I don't know, probably not.

What is Nix

The term is a bit confusing, because it means multiple things at once. But in short there are:

  • Nix Language
  • Nixpkgs the package repository
  • Nix CLI
  • Nix OS

The most important thing to know is that you can use any subset of these and still get some benefits of Nix. See [What to use Nix for](#What to use nix for) for some examples. Xe Iaso has aa nice image explaining some of these relationships on their blog.

Installing Nix

There is the official way, but it's probably better to start using the Determinate Systems' Nix Installer, mainly because it enables flakes by default and it's a lot easier to uninstall if you want to do that:

curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install

Nix Language

The language is used across Nix to write all the instructions and descriptions of packages and how to install them. But a simple way to think of it is like a language that makes JSONs.

It does not look like it, but its a fully-fledged functional programming language that skews towards map-like data structures. The language relies heavily on a data structure called attrset or sometimes just set, which is somewhat similar to JSON or e.g. Python's dict, but has some really nice quality of life properties on top.

Probably the best place to learn the Nix Language is The Manual, but it is written like an explanation of the BNF so if you're not familiar with that, there is a very nice one-pager Nix tutorial Nix 1P.

Nixpkgs

The gateway to Nixpkgs is its Search Page. From there you can find all the packages that are available for easy use within the Nix ecosystem, but being a build system, if your package is not present, getting it installed through Nix is probably easier than you think.

When you find your package on Nixpkgs, there are helpful links. One to the package Homepage, which is usually the GitHub repo, but the other called Source goes to NixOS/nixpkgs on GitHub. This is a magical place where all the packages are defined and its a great source of examples if you're trying to build your own packages.

Nix CLI

There are 2 kinds of commands, the legacy (e.g. nix-shell) and the new Flake-based (e.g. nix flake, nix develop, etc.).

  • nix --show-trace <command> is useful when you need more debugging information
  • nix run <app> will run a binary, downloading and building it if necessary, e.g. nix run deadnix
  • nix flake check will evaluate the nix expression in flake.nix and check all the assertions and checks. This can help you find issues without even building anything
  • nix flake update will update the flake.lock with the newest versions of inputs
  • nix flake lock --update-input nixpkgs will update flake.lock with the newest version of nixpkgs input

NixOS

I'm using NixOS on 2 Raspberries and a Mini PC headless. This is where the Nix Option search comes in handy the most, all of the OS things that can be configured have an Option and they are pretty well documented.

I don't have much of a reference point with other Linux distros that I would use regularly, but I think its already a testament to Nix that it got me excited about Linux again and I began investing in PC hardware.

Why use Nix

There are many reasons, including the ones mentioned on the Nix Homepage, like Reproducible, Declarative and Reliable, but for me the main reason is that, after some initial hurdles, it's very pleasant to work with. It just feels good to make some changes to my flake and do a make switch and it goes ahead and applies them. And I know that if it worked once, there is a very good chance it will continue working.

In the past I've set up many computers, from my laptops to VPSes and it's always a crushing feeling to have to start over with something you've already done because the OS upgrade messed up. I don't like that!

I guess that feeling comes down to the Declarative bit. The configuration is written as a set of files and it's only those files that you need to re-create the box the same way it was, if you accidentally mess up.

What to use Nix for

Here is where I think most Nix tutorials are lacking. When I started with Nix, I was struggling to understand which parts work together and how and what are the kind of things I can do with Nix. So here I'm going to try to summarize the kind of use cases I have found useful with Nix.

Dev environments

This can be done without the need for any other of the below use cases. This is hard to stress enough! The basic steps are:

  1. Install Nix
  2. Set your repo up with a flake
  3. Done

You can just use nix to get a reproducible environment in a single repo you have checked out locally and nothing else. It does not interfere with the rest of the system, you can confinue using your fav OS, your favourite package manager (Brew, or whatever, or even no package manager).

When this is set up, all you do is cd into the project and you're in your reproducible fixed shell managed by Nix. Nix will replace your $PATH with a path to /nix/store where your project's dependencies are installed, so whatever you do to your system outside the repo has no effect inside the repo folder.

Commit the flake and all your colleagues who have Nix set up and check out the repo will have the exact same software installed on their computers.

When you have multiple repos, each with their own Nix setup, Nix will maintain an union of all the necessary packages, so if 2 repos are using the same Python, it will only be installed once, but if they are using two different Python versions, each will have their own. It will only be active in that repo's directory.

Examples of how to use dev environments with flakes can be found in the templates of my dotfiles. I use these when starting a new project like this:

nix flake init -t "github:mirosval/dotfiles?dir=templates#rust"

I can't remember that so I have a cheat code:

# Initialize new nix project from flake template
nix flake --show-trace init -t "github:mirosval/dotfiles?dir=templates#<template>"

$ template: ls -1 -F $HOME/.dotfiles/templates | grep '/$' | sed -e 's|/$||' | sort

Home

You can manage your home config (dotfiles) using Nix. This is mainly interesting if you are on MacOS or on Linux and don't want to switch to NixOS. It is a step up from just using Nix for dev environments. Now you are using that same /nix/store as before, but $PATH is modified globally for your user, not just in the repositories that are using Nix.

You can now use Nix to create and manage your dotfiles for you instead of using a tool like Chezmoi or managing them yourself using shell scripts (which is what I did prior to Nix). This is nice, because you visit and apply your entire configuration every time you do a switch. No more install scripts that only run the first time you're setting up your machine and need maintenance every time you need them. Conversely it will also remove files that are no longer linked. So if you remove a program that had an entry in ~/.config that was linked to the /nix/store, that entry is also removed.

Additionally this allows you to define a set of packages that should be installed and are available on your $PATH. For example if you are using Neovim, you can get rid of your Neovim package manager and instead use Nix to install your plugins. If you've ever ran :PlugUpgrade or :PackerSync and ended up with a broken Neovim, because of a breaking change in one of your plugins with no way back to the previous state, do I have good news for you! With Nix you can simply switch to the previous generation to immediately have a working system again and you can deal with the breakage at the time of your choosing.

The component that enables this is called Home Manager, it has a manual and option search.

{
  description = "Miro's dotfiles";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
    home-manager = {
      url = "github:nix-community/home-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { nixpkgs, home-manager, ... }:
    let
      system = "aarch64-darwin";
      pkgs = import nixpkgs { inherit system; };
    in
    {
      homeConfigurations.mirosval = home-manager.lib.homeManagerConfiguration {
        modules = [
          ({ lib, ... }: {
            programs.home-manager.enable = true;
          })
        ];
      };
    };
}

Local Computer

Another level on top of Home Manager is managing the whole computer using Nix. I'm not sure how this works on Linux, because there I've always skipped directly to NixOS, but in theory this is where Nix would cooperate with your distro to manage higher level settings.

On MacOS, the tool to do this is called nix-darwin. It can maage system-level things, like Users, MacOS-specific settings, but also you can use it to install software from Brew or the Mac App Store.

On Linux, this is where we get to NixOS. Its a full operating system that you can install "traditionally" using an ISO or a USB stick. But there are also some crazy ways, like Nixos-infect which will convert an existing Linux installation into a NixOS installation. This is especially interesting if you want to run Nix on a VPS and your VPS provider does not offer NixOS as one of the options.

A minimal nix-darwin setup looks like this:

{
  description = "Miro's dotfiles";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-23.05-darwin";
    home-manager = {
      url = "github:nix-community/home-manager/release-23.05";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    darwin = {
      url = "github:lnl7/nix-darwin";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = inputs@{ self, nixpkgs, home-manager, darwin, ... }: {
    darwinConfigurations.mirosval = darwin.lib.darwinSystem {
      # If you need to support more systems, use https://github.com/numtide/flake-utils
      system = "aarch64-darwin";
      modules = [
        ./hosts/mirosval/default.nix # <-- Your nix-darwin config goes into this file
        home-manager.darwinModules.home-manager
        {
          nixpkgs = nixpkgs;
          users.users."mirosval".home = "/Users/mirosval";
          home-manager.useGlobalPkgs = true;
          home-manager.users.mirosval = home-manager.lib.homeManagerConfiguration {
            modules = [
              ({ lib, ... }: {
                # Your Home-manager config goes here
                programs.home-manager.enable = true;
              })
            ];
          };
        } # No comma or colon here because this is the end of a List element
      ];
    };
  };
}

Remote Computer

This is where my knowledge ends for the time being, I know that there are tools like NixOps, Morph, Colmena or deploy-rs. It is possible to manage remote nix installs with these but I have no experience with any of them yet.

I have some experience cross-building Raspberry PI images with NixOS. The basic workflow is to first create a builder, which is a docker image with NixOS running on it. You can use Docker's ability to switch target platforms to pretend that this is a computer on a different architecture. This is needed so that you can cross build raspberry aarch64-linux on aarch64-darwin or x86_64-linux. Then you instruct the builder to create the whole NixOS install and package it into an SD-card image. Now you can take this image to your Raspberry and you have NixOS on Raspberry!

Other usecases

I have seen others do these but I don't have experience with them myself

CI

Using Nix on the CI is pretty straightforward, there is a Nix Installer GitHub Action. There is also Cachix to speed up your builds.

Docker Images

You can get Nix to build Docker images for you, this fits well, because an ideal Docker image only has the application and the minimum dependencies needed to run the application. Which is not the case if you build using the standard approach, especially not if you work with scripting languages like Python.

How to go about learning Nix

The key is to realize that Nix language is just a fancy way to write a single attrset (think a single big JSON). This is a description of what you would like to happen. Then various different Nix tools or commands take that description and do something with it.

With that in mind, there are two main questions you want to answer when working with nix:

(1.) What to write in my Nix file to create the right description

{
  outputs = _: {
    darwinConfigurations.mirosval = ???; # What goes here???
  };
}

In other words, what is the schema? This confused me a lot in the beginning, because in theory you can return anything as a part of your attrset, however if you want to achieve specific things, you need to return specifically formatted attrsets.

The first place this is documented is the Flakes Nix Wiki. That lists the top level keys you can use in a flake. But it's not super helpful, because:

  # Used by `nix develop`
  devShells."<system>".default = derivation;

is nice, but what's a derivation? It turns out that the correct answer there is mkShell (yes, technically you can put other things there).

It also looks like Determinate Systems has put some effort into collecting these schemas.

(2.) What tool to use to do something with my description

{
  outputs = _: {
    darwinConfigurations.mirosval = _;
    # ^ having this, what do I invoke on the command line?
  };
}

Or what to run to get it to work. The confusion there is related to the Flake split, but I think, generally speaking commands of the form nix-* are legacy and nix * are Flake-based. So for instance nix flake check will validate the assertions that are specified in a Flake and report any errors.

So specifically, for a nixos configuration:

{
  outputs = _: {
    nixosConfigurations.mirosval = _;
  };
}

you have to switch to it using:

nixos-rebuild switch --show-trace --flake .#mirosval

For a nix-darwin configuration:

{
  outputs = _: {
    darwinConfigurations.mirosval = _;
  };
}

you have to switch to it using:

nix build .#darwinConfigurations.mirosval.system
result/sw/bin/darwin-rebuild switch --flake .#mirosval

I'm using Nil LSP as my LSP server (with Neovim), additionally I've found statix to be very helpful in understanding Nix language patterns. If you have nix, you can run it simply with nix run nixpkgs#statix check. Similarly you can use deadnix to find unused code: nix run nixpkgs#deadnix.

To find nix packages, I rely on the DuckDuckGo Bangs, so for example !nixpkgs deadnix goes to Nixpkgs search results for deadnix. You can use !nixopt to search through Nix options.

Common errors

Forgetting to git add a file

error: getting status of '/nix/store/mhni7gc5azcggzl4rqqd78w4i7clp43i-source/home/aaaa': No such file or directory

When you are adding files that you want to reference from your flake.nix, they need to be added to git using git add <file> before the flake can be built. If you forget to do that you get an error like this.