Why Nix?

17/08/2024

As a compulsive distro-hopper and programmer who likes to try new languages a lot of my time is spent on setting up new systems and packages. Usually I end up with a hodge-podge of multiple docker images, Node/Ruby/Python versions flying around and general upkeep & maintenance is difficult. Which is what always made Nix so appealing to me.

Package mess

I will not go into the details of Nix but just give a few one-liners. Nix is -

  • A Linux distro
  • A package manager
  • A programming language

among other things. I do not use the NixOS distro (although it looks appealing), I mainly use the package manager and programming language (not an expert by any means). Here is a little explanation of these -

  • Nix is a purely functional package manager. This means that it treats packages like values in purely functional programming languages such as Haskell — they are built by functions that don’t have side-effects, and they never change after they have been built. Nix stores packages in the Nix store, usually the directory /nix/store, where each package has its own unique subdirectory. Read more here.
  • The Nix programming language is very declarative, like JSON or TOML but it also has functions (hence it is a functional language). See more here.
  • So in a very, very short sentence - you can use Nix packages and the Nix language to declare an "environment" you need. This environment will not only be repeatable (i.e. coded) but also completely reproducible (i.e. everyone will have the same environment) due to the very nature of Nix.

I recommend avoiding pain and setting up Nix from here - https://zero-to-nix.com/start/install

A very basic nix file can look like this -

# shell.nix
let
  nixpkgs = fetchTarball "https://github.com/NixOS/nixpkgs/tarball/nixos-24.05";
  pkgs = import nixpkgs { config = {}; overlays = []; };
in

pkgs.mkShellNoCC {
  packages = with pkgs; [
    cowsay
    lolcat
  ];

  GREETING = "Hello, Nix!";

  shellHook = ''
    echo $GREETING | cowsay | lolcat
  '';
}

Here we get the packages from a certain repository and install 2 packages "cowsay" and "lolcat". If you now run -

nix-shell

_____________
< Hello, Nix! >
-------------
        \   ^__^
        \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

You will see the packages being installed, and the shell hook greeting you. Please note that your base Linux host may or may not have these packages but Nix will download them from the Nix repos into "/nix/store/" and set the right PATH and do everything for you.

This is great because now we want to move to bigger things like setting up a basic project in a new language, let's say in Deno or Gleam and I do not want to pollute my host system.

Before we move on, I want to talk a bit about Nix Flakes -

  • Nix flake is not an "official" system but it is so well used that it should not go away now
  • Flakes are a "system" for managing dependencies between Nix
  • Flakes allow distribution and extension of Nix expressions
  • Flakes allow you to "lock down" dependencies, like lock files found in most package managers

Again I do not claim to be a Flake expert. But below is a flake which is just enough to setup Deno -

# flake.nix
{
  description = "Hello Deno";

  # Flake inputs
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
  };

  # Flake outputs
  outputs = { self, nixpkgs }:
    let
      # Systems supported
      allSystems = [
        "x86_64-linux" # 64-bit Intel/AMD Linux
        # "aarch64-linux" # 64-bit ARM Linux
        # "x86_64-darwin" # 64-bit Intel macOS
        # "aarch64-darwin" # 64-bit ARM macOS
      ];

      # Helper to provide system-specific attributes
      forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f {
        pkgs = import nixpkgs { inherit system; };
      });
    in
    {
      # Development environment output
      devShells = forAllSystems ({ pkgs }: {
        default = pkgs.mkShell {
          # The Nix packages provided in the environment
          packages = with pkgs; [
            zsh
            lolcat
            deno
          ];

          # Environment variables
          env = {
            GREETING = "Hello from Deno!";
          };

          # A hook run every time you enter the environment
          shellHook = ''
            echo $GREETING | lolcat
          '';
        };
      });
    };
}

Flakes provide a lot of reusability, for example functions to build for multiple CPU architecture. All Flake files have 2 main sections - inputs & outputs. Flakes are also very declarative, so you can pretty much read the file above and get an idea of what is going on. Main thing here is that we have pulled a "deno" package. So now if you run -

nix develop

you will get all your dependencies and nix will drop you in a bash shell. If you want to stay in your shell you can also do -

nix develop -c $SHELL

You will now have "deno" executable available. You can also do for example -

code .

This would open VSCode in the context of the nix shell which means VSCode has the Deno executable available. You can do the same with emacs or neovim (whatever you want). You can now git clone this project and get started and running with Deno. Exit the nix shell and you are back to your pristine host system.

Not only is your host system clean but now you can also have a per project development environment with the dependencies locked down and available to all. This is now my default setup for all projects. Nix can do much more like build your final distribution or even build Docker images in a reproducible way. All in all - Nix is just great!