Vilhelm Bergsøe

Nix is pretty awesome ❄️

Wed 05 Apr 2023 - 7 min read

Introduction

I have known of Nix and NixOS for a while, and I've always found them very interesting. I even dabbed with NixOS in a rudamentary way on my Thinkpad x220, which I utilize as my home server.

However, I was not leveraging Nix to its full potential, as I managed most aspects of my system in an imperative manner, negating the advantages offered by Nix.

Recently, my desktop drive went kapoot, which I viewed as the perfect opportunity to migrate to NixOS and get a proper and well-structured configuration up and running.

This blog post primarily serves as an introduction and a quick showcase of what Nix can accomplish and how I use it. It does not aim to provide an exhaustive guide to learning Nix. For comprehensive learning resources, please refer to the end of this blog post.

What is Nix?

First off, what even is Nix?

As stated on their the official Nix website, "Nix is a tool that takes a unique approach to package management and system configuration."

While this is a good one-line summary of Nix, I will try and provide a more detailed explanation.

When people mention Nix, they typically refer to one of three things:

  • Nix - the programming language

    Nix - the programming language is a purely functional and declarative programming language.

  • Nix - the package manager

    Nix - the Package Manager is a cross-platform package manager that has access to the Nix Packages collection (nixpkgs) which has a set of over 80,000 packages and can be installed on Linux and other Unix-like systems like macOS.

  • NixOS - the Nix based linux distro

    NixOS is a Linux distro built around the Nix package manager and the declarative Nix programming language. It is designed to provide a consistent and reproducible system from one source of truth configuration.

Exploring Nix

All packages, referred to as derivations in Nix-land, are stored in the /nix/store, which is an immutable, read-only directory that stores all of a system's derivations.

Each derivation and its dependencies are stored in a unique directory within /nix/store, with the directory name being a cryptographic hash of the package contents and its dependencies.

This helps ensure that each package and its dependencies are isolated and independent of other packages, which is important for reproducibility.

Here is an example path to GNU hello:

/nix/store/1pry7pnxqig0n2pkl4mnhl76qlmkk6vi-hello-2.12.1/

Inside we find a bin and share directory, which each hold files associated with this derivation.

$ tree -L 2 /nix/store/1pry7pnxqig0n2pkl4mnhl76qlmkk6vi-hello-2.12.1
/nix/store/1pry7pnxqig0n2pkl4mnhl76qlmkk6vi-hello-2.12.1
├── bin
   └── hello
└── share
    ├── info
    ├── locale
    └── man

5 directories, 1 file
vb@buckbeak:~]$ 

Under share, we find the typical /usr/share entries for man pages and such. Under bin, we actually find the binary hello, and we can execute it just fine:

$ /nix/store/1pry7pnxqig0n2pkl4mnhl76qlmkk6vi-hello-2.12.1/bin/hello 
Hello, world!

Nix for Reproducible Development

One really cool use for Nix is its ability to create reproducible development environments.

For example, let's say we have the following C program that relies on SDL:

#include <SDL2/SDL.h>
#include <stdio.h>

#define SCREEN_WIDTH 640
#define SCREEN_HEIGHT 480

int main(int argc, char* args[]) {
  SDL_Window* window = NULL;
  SDL_Renderer* renderer = NULL;
  if (SDL_Init(SDL_INIT_VIDEO) < 0) {
    fprintf(stderr, "could not initialize sdl2: %s\n", SDL_GetError());
    return 1;
  }
  window = SDL_CreateWindow(
      "simple_c",
      SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,
      SCREEN_WIDTH, SCREEN_HEIGHT,
      SDL_WINDOW_SHOWN
  );
  if (window == NULL) {
    fprintf(stderr, "could not create window: %s\n", SDL_GetError());
    return 1;
  }
  renderer = SDL_CreateRenderer(window, -1, 0);


  // Event loop
  SDL_Event event;
  int quit = 0;
  while (!quit) {
    while (SDL_PollEvent(&event)) {
      if (event.type == SDL_QUIT) {
        quit = 1;
        break;
      }
    }

    SDL_SetRenderDrawColor(renderer, 18, 18, 18, 255);
    SDL_RenderClear(renderer);
    SDL_RenderPresent(renderer);

    SDL_Delay(10);
  }

  SDL_DestroyWindow(window);
  SDL_Quit();
  return 0;
}

If we wanted a guarantee that this program would compile, we would have to know for sure that we have all the dependencies and the right version of the dependencies.

This is a bothersome task, and everyone who has had to compile software on their own machines knows the struggle of getting the dependencies right.

Luckily, Nix comes to the rescue!

We can simply create a Nix flake, which is an experimental standard schema for defining both inputs and outputs of your application.

This flake.nix file in the project root defines the flake, which specifies how to build the derivation, as well as the necessary dependencies for building and development shell environments:

{
  description = "A simple C program that uses SDL";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = {
    self,
    nixpkgs,
    flake-utils,
    ...
  }:
    flake-utils.lib.eachDefaultSystem (system: let
      pkgs = nixpkgs.legacyPackages.${system};
    in rec {
      packages = {
        default = pkgs.stdenv.mkDerivation {
          name = "simple_c";
          src = ./.;

          buildInputs = with pkgs; [
            pkgconfig
            SDL2
            gcc
          ];

          buildPhase = ''
            gcc ./main.c -o simple_c `pkg-config --libs --cflags sdl2`
          '';

          installPhase = ''
            mkdir -p $out/bin
            cp simple_c $out/bin
          '';
        };
      };
    });
}

If we then want to build the derivation, we can simply run:

$ nix build
$ # or
$ nix develop

which will build the project and create a shell with all dependencies in path, respectively.

nix build will create a directory called result in the working directory, and in it, there is a bin directory that contains our binary. The keen among you, you might have figured out that result is actually a symlink to the /nix/store.

$ ls -lah result
lrwxrwxrwx 1 vb users 52 Apr  5 20:25 result -> /nix/store/3mjvzschwmxivpmhm54x43djja35mim3-simple_c

A really neat piece of software that works super well with Nix is direnv.

With direnv installed and setup on your system, you can add the following to a .envrc file in your project root:

$ echo "use flake" >> .envrc
$ direnv allow

This will automagically ✨ trigger the Nix shell and have your dependencies loaded in your path when you change your working directory to the project root.

The flake.lock file is an important aspect of Nix's reproducibility. Acting as a lockfile for all dependencies, it ensures that the input defined in the flake, such as inputs.nixpkgs, is locked to a specific revision/commit using a sha256 hashsum.

In the flake, the inputs.nixpkgs.url is the link to the actual nixpkgs repo, which contains all the packages and dependencies. By locking the input, Nix guarantees that everything will build exactly the same way on your and your coworker's machines.

There is so much more cool stuff you can do with Nix alone, but I think this gives you a pretty good idea of how useful it is.

Let's take a look at NixOS..

NixOS: Declarative System Configuration

NixOS is really exciting because it takes the same principles of declarative structure of a project and applies it to your entire system.

Here is an example of how you can enable PostgreSQL and Nginx declaratively on your NixOS machine.

In your NixOS configuration, you simply add:

services.postgresql.enable = true;
services.nginx.enable = true;

Really, it's that simple.

NixOS's declarativeness isn't limited to services though. You can configure quite literally everything. Here is a snippet from my own configuration:

# Configure user
users.users = {
  vb = {
    initialPassword = "nixos";
    isNormalUser = true;
    openssh.authorizedKeys.keys = [
      "..."
    ];
    extraGroups = ["wheel" "networkmanager" "docker"];

    shell = pkgs.bash;
  };
};
# ...
# Configure networking
networking = {
  hostName = "buckbeak";
  networkmanager.enable = true;
  nameservers = ["1.1.1.1" "1.0.0.1"];
};

NixOS's configurability even extends to custom modules.

Here is the same Nix module used to host this site on my server:

{inputs, ...}: {
  systemd.services.site = {
    enable = true;

    description = "my site";
    wantedBy = ["multi-user.target"];
    after = ["network.target"];

    serviceConfig = {
      Type = "simple";
      ExecStart = "${inputs.site.packages.x86_64-linux.default}/bin/site";
      Restart = "on-failure";
    };
  };
}

It uses the inputs.site which is an input defined in my configuration's flake.nix that refers to the GitHub repo for the site:

inputs = {
  # Nixpkgs
  nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";

  # ...

  # Site
  site.url = "github:vilhelmbergsoe/site";
};

In the site repository there is a flake.nix in the root, which uses crane for handling source fetching from the Cargo.lock file and incremental building.

It's really quite something 😁

Closing thoughts

I have had a blast learning Nix for the past month, and I think this is an amazing tool. I have moved my entire desktop configuration to NixOS as well as my server.

And while there is so much I haven't covered here, I hope this gave someone the itch to pick up Nix and try it out for themselves.

If it did and you want to learn more, here are some learning resources to help you get started with:

If you're interested, you can also find my configuration here.

Thanks for reading!

tags: [nix, nixos, learning]