Kevin De Baerdemaeker

How-To Nix (yet another guide to development environments)

You've probably heard of Nix. If you haven't, well... The landing page explains everything (just trust me 👀), or maybe it confuses you even more.

Declarative builds and deployments. Nix is a tool that takes a unique approach to package management and system configuration. -- https://nixos.org/

eAsY rIgHt?! The same way the monad is just a monoid in the category of endofunctors makes zero sense for me, Nix's description is bit obtuse. Once the concepts start making sense, it ruins you in a good way. Let's build a tiny program using Nix, and slowly understand why isolated development environments are valuable.

Installing Nix

The tooling is available on Linux, MacOS, WSL2, and more. If you want to immediately go balls deep you can install NixOS on a useless laptop. The first time I tried out Nix was on an Ubuntu VM, because I didn't want to screw up my existing Linux installation.

We'll use Nix Flakes (more on that later), which is still considered experimental, but in my opinion essential to ensure a reproducible and isolated development environment. In order to enable the flakes ecosystem, you can temporarily enable it when running the nix command.

nix --experimental-features 'nix-command flakes' help

However, typing it out every time quickly becomes annoying, so you can append it to nix's configuration file.

mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf

And if you are using NixOS (nice!). You can add it to your configuration.

nix.settings.experimental-features = [ "nix-command" "flakes" ];

Messing Around with Nix Packages

Let's say you are writing a blog post, and want to showcase a package hello without polluting your existing Linux installation. I do not have this package installed, nor do I need it outside the scope of this article. Instead of installing it, and then forgetting to clean it up, we can ask Nix to temporarily make it available.

The nix shell command starts a new shell and brings packages from the nixpkgs flake into the shell environment.

nix shell nixpkgs#hello nixpkgs#cowsay
hello | cowsay
 _______________
< Hello, world! >
 ---------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Once you exit the shell, there's no more hello, and no more cowsay. It's as if these packages were never installed. In reality, they are still downloaded and locally available on your system. If you were to create a shell with the same packages Nix only has to create a symbolic link to make them available for use.

# NOTE(Kevin): Notice how fast it executes
nix shell nixpkgs#hello nixpkgs#cowsay --command sh -c "hello | cowsay"

If you want to try out other packages, you can search the Nix Package Repository using nix search nixpkgs name-of-package or via the website. There's plenty of packages to try out: CLI tools, emulators, editors (yes neovim), and even more obscure packages might be readily available. According to Repology the list of available packages looks huge, even compared to the AUR.

An Actual Practical Guide to Nix Flakes

I don't know about you, but I'm getting tired of constantly typing hello and cowsay whenever I want an elevated shell with these packages. More importantly, how do we ensure these packages behave exactly the same way on different machines? There's a chance the channel version of the nixpkgs is different than mine, which can yield different results on different machines.

In order to properly pin down the exact version of nixpkgs we are going to build a Nix Flake. Let's initialize our flake, and inspect what it created. The flake.nix file looks somewhat familiar to a package.json, except nix is an actual programming language for configuring systems.

mkdir -p first-flake
cd first-flake
nix flake init
cat ./flake.nix

The inputs specify the dependencies of the flake. In our case, we depend on the nixpkgs repository under the nixos organization using the nixos-unstable branch. The description can be whatever you feel like.

If you are unfamiliar with language, the outputs part can look confusing. In Javascript, this would be somewhat equivalent to const outputs = ({ self, nixpkgs }) => ({}).

{
  description = "A very basic flake";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
  };

  outputs = { self, nixpkgs }: {

    packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;

    packages.x86_64-linux.default = self.packages.x86_64-linux.hello;

  };
}

Let's change the description, remove the two packages references, and make sure the inputs depend on the more stable nixos-23.11 branch.

The system variable depends on what processor architecture your system uses. There's ways to support multiple systems, but that's an exercise for the reader. The pkgs variable is how we get access to packages available for our processor architecture.

Here's a list of possible options (you should change my aarch64-linux to a value that works for your architecture):

{
  description = "Hello World from Nix";
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
  };

  outputs = { self, nixpkgs }:
  let
    system = "aarch64-linux";
    pkgs = nixpkgs.legacyPackages.${system};
  in
  {
    # TODO(Kevin): Add isolated dev environment
  };
}

Even though we chose a stable version for the nixpkgs, it's still not clear how Nix Flakes pins down the exact version. The nix flake lock command generates a lock file, which snapshots the exact git hash the flake uses for the packages. We have to track the flake.nix with git, otherwise the flake commands won't pick it up.

git add flake.nix
nix flake lock
cat ./flake.lock
{
  "nodes": {
    "nixpkgs": {
      "locked": {
        "lastModified": 1713145326,
        "narHash": "sha256-m7+IWM6mkWOg22EC5kRUFCycXsXLSU7hWmHdmBfmC3s=",
        "owner": "nixos",
        "repo": "nixpkgs",
        "rev": "53a2c32bc66f5ae41a28d7a9a49d321172af621e",
        "type": "github"
      },
      "original": {
        "owner": "nixos",
        "ref": "nixos-23.11",
        "repo": "nixpkgs",
        "type": "github"
      }
    },
    "root": {
      "inputs": {
        "nixpkgs": "nixpkgs"
      }
    }
  },
  "root": "root",
  "version": 7
}

From this point forward, if two machines use the exact same flake.lock, the packages and their dependencies will be exactly the same on both machines. But what packages though? We haven't referenced any so far. You are right! Remember the TODO from earlier?

The devShells.${system}.default is an attribute that's expected by the flake when we wish to setup a shell. The pkgs.mkShell is a function which expects an attribute set. In this attribute set we can define our packages, by referencing them via the pkgs variable.

{
  description = "Hello World from Nix";
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
  };

  outputs = { self, nixpkgs }:
  let
    system = "aarch64-linux";
    pkgs = nixpkgs.legacyPackages.${system};
  in
  {
    devShells.${system}.default = pkgs.mkShell {
      packages = [
        pkgs.hello
        pkgs.cowsay
      ];
    };
  };
}
git add flake.nix
nix flake lock

Now that we've added these packages, everyone who uses nix with flakes, can run nix develop to setup the shell, which provides the packages based on the flake.nix. Remember that the flake.lock pins down these packages to the exact same version, meaning that the packages and their dependencies will be exactly the same on different machines.

nix develop
hello | cowsay

It's also still possible to invoke the commands using a one-liner.

nix develop --command sh -c "hello | cowsay"
 _______________
< Hello, world! >
 ---------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Reproducible and Isolated Development Environments for Any Language

If you are familiar with the Node Version Manager or Python's Virtual Environments, you can draw some similarities. Except, you are able to setup the development environment independently from the languages itself. Are you working on a project which requires nodejs_18 and python3? Done. Do you want to provide a default debugger when working on a C/C++ project? Done. Do you want setup an environment for Github Actions? Done!

If a Javascript project use nix already, anyone who clones the project should be able to run nix develop and have a fully working development environment available. For the sake of the article though, let's assume you want to contribute to a fictional project which uses bun. You throw out your computer, because you didn't know there was yet another way to run Javascript...

Anyways, you add pkgs.bun to the list of packages inside the pkgs.mkShell set.

{
  description = "Hello World from Nix";
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
  };

  outputs = { self, nixpkgs }:
  let
    system = "aarch64-linux";
    pkgs = nixpkgs.legacyPackages.${system};
  in
  {
    devShells.${system}.default = pkgs.mkShell {
      packages = [
        pkgs.hello
        pkgs.cowsay
        pkgs.bun
      ];
    };
  };
}
git add flake.nix
nix flake lock

Once we start the shell with nix develop, we are able to run our complicated app using bun (as recommended by the fictional project maintainers). Our application at home:

console.log("Hello from Nix and Javascript!");

If you would like to work inside the development environment provided by nix, we can again run nix develop, and run our code. Remember, cowsay is still available because we haven't removed it yet from the flake.nix. The flake pins down bun's version, so you are running the exact same version as these fictional project maintainers.

nix develop
bun ./hello.js | cowsay

Or using the one-liner.

nix develop --command sh -c "bun ./hello.js | cowsay"
 ________________________________
< Hello from Nix and Javascript! >
 --------------------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Building and Packaging Applications with Nix

Bundling an application for Node.js is slightly more involved, and is going to be an exercise for the reader. We're going to write a small C program and ship it with nix. Don't worry, nothing fancy here.

#include <stdio.h>

int main() {
   printf("Hello from Nix and C!");
   return 0;
}
git add flake.nix src/hello.c
nix flake lock

Remember the packages we've removed after running nix flake init? It's time to bring them back, but tailor them to help us build the C program. The pkgs.stdenv.mkDerivation is function with a set as it's argument. The attributes pname, version and src are required.

In buildPhase we tell nix how which compiler to use to build our program. The installPhase places the executable in a bin folder so nix knows where to find it when you want to run the application. By default nix run looks inside the ./result/bin/ folder for an executable with the name taken from pname.

{
  description = "Hello World from Nix";
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
  };

  outputs = { self, nixpkgs }:
  let
    system = "aarch64-linux";
    pkgs = nixpkgs.legacyPackages.${system};
  in
  {
    devShells.${system}.default = pkgs.mkShell {
      packages = [
        pkgs.hello
        pkgs.cowsay
        pkgs.bun
      ];

      inputsFrom = [
        self.packages.${system}.myHello
      ];
    };

    packages.${system} = {
      myHello = pkgs.stdenv.mkDerivation {
        pname = "my-hello";
        version = "v0.0.1";
        src = ./src;

        # NOTE(Kevin): We don't have to go into ./src during the phase, because
        # it assumes the working directory to already include the files from src
        buildPhase = ''
          gcc hello.c -o my-hello
        '';

        installPhase = ''
          mkdir -p $out/bin
          cp my-hello $out/bin/
        '';

        buildInputs = [
        ];
      };
      default = self.packages.${system}.myHello;
    };
  };
}

Running nix build builds our application, the $out variable translates to the ./result folder. After building it, you can run the executable via ./result/bin/my-hello.

nix build
nix shell nixpkgs#tree -c tree result
result
└── bin
    └── my-hello

2 directories, 1 file

Or use nix run, which is the equivalent of running nix build && ./result/bin/my-hello without creating the ./result folder inside the current working directory.

nix run
Hello from Nix and C!

The perceptive readers might be thinking: "Wait a darn minute! We are missing pkgs.gcc, it's not part of the flake? Stop fooling us!

The buildInputs is the one responsible for defining all the build dependencies of our package. Yet, we don't need it here because mkDerivation is a helper that already bundles gcc by default. These potential dependencies are also exposed to the isolated development environment via inputsFrom. For a project with actual buildInputs look here.

The packages from the development environment (nix developer) are purposely not available during nix build and nix run, in order to build and link the packages in complete isolation.

Of course running nix develop in combination with nix run is not a problem.

nix develop -c sh -c "nix run | cowsay"
 _______________________
< Hello from Nix and C! >
 -----------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

Conclusion

There's so much to learn about the Nix ecosystem, as we've barely touched the surface. I'm personally still learning a lot about it, even while writing this article. The main draw for me to use Nix is being able to jump in and out of a project, and have the whole development environment setup using nix develop.

If you wish to see a more production level flake.nix, you can check out my personal website, uniorg (a project I contribute to) or my dotfiles (nix configuration for my laptop, desktop, and macbook).

Fun Fact

Nix sounds like "nothing" for a Belgian, so when I'm talking in Dutch about nix, it often get confusing. I'm trying to understand nothing.