X's weird webspace

making a Nix dev-shell

or 'why I won't use docker anymore' 👽
from 22/07/2024, by xtrm — 6m read


Welcome to this short and sweet article on getting rid of Docker containers and VMs using one of Nix's greatest features: dev-shells.

This tutorial will be straight to the point without too much boiletplate in terms of explanations. I will also not take too much time explaining the nix language that will be utilized, but we won't dive very deep in terms of functionnality. Think of it like good ol' JSON and it'll be fine.

If you want to familiarize yourself with the official documentation, you can do so here.

📚 table of contents

🚀 getting started

First off let's get our definitions straight:

A dev-shell is a shell that we'll be using to get dependencies, libraries, and tools for development on a project. This follows the Nix philosophy of context-dependant and minimal scoping. This basically means that this dev-shell will only be available to us when we ask it to, and will not modify the base system in any way.

That being said, let's create our first dev-shell! To declare a dev-shell, we'll need to create a shell.nix file. Let's see a very empty one:

## Our little development shell
{
  pkgs ? import <nixpkgs> { }
}:

pkgs.mkShell {
  # A shell!
}

This looks like black magic, but let's unpack it. This is basically a function:

  • the { ... } at the top is the function inputs
    • we take an argument, pkgs, which is a reference to our system's nixpkgs (don't worry about the rest)
  • after : is the function code, calling mkShell with some arguments, defined in { }

To run this file and create our shell, we can use the nix-shell command.

We either need to point it to the shell file, for example: nix-shell my-simple-shell.nix, or we can name our file shell.nix, and nix-shell will automatically detect it:

$ ls
shell.nix

$ nix-shell
## ...

[nix-shell]$ echo Hi!
Hi!

🎉 There we go, our first shell!

For now we don't specify anything, so it does pretty much nothing, but it's there!

We can exit the shell via the exit builtin or by doing CTRL + D, which cuts stdio and returns nicely.

[nix-shell]$ exit
exit

$ # Returned to bash

🔨 upgrading our shell

There's quite a few things we can have inside our shell, such as environment variables. We can also add a shell hook that will be run on shell initialization.

We can go a bit further and ask it to fetch us a Nix package (those can be found on NixOS's nixpkgs search page), that we can use in our hook. That way anyone using our shell will have the same experience.

Let's try that with cowsay.

Here's our modified file:

{
  pkgs ? import <nixpkgs> { }
}:
 
pkgs.mkShell {
  # We define the hook in a multi-line string
  # Notice the interpolation with ${...}
  shellHook = ''
    echo "Hello, $WHO!" | ${pkgs.cowsay}/bin/cowsay
  ''; 

  # Let's also define an environment variable
  WHO = "Nix";
}

When running this with nix-shell, Nix downloads and configures the cowsay package for us, and we get a nice output:

$ nix-shell
## <snip>
 _____________ 
< Hello, Nix! >
 ------------- 
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

[nix-shell]$ echo $WHO
Nix

At this stage, we only asked Nix to use the package for the shell hook, so it is not available to us on the PATH for example:

[nix-shell]$ cowsay
bash: cowsay: command not found

👨‍🏭 let's get our /bin/aries...

...and our libraries as well. This is where it gets interesting.

We can add a nativeBuildInputs section to our mkShell call, and declare packages that will be added to our PATH. Similarly, we can also declare libraries inside which will become available to our compilers.

Let's now move cowsay to be in our PATH. Here's the updated file:

{
  pkgs ? import <nixpkgs> { }
}:
 
pkgs.mkShell {
  # We use the 'with <>;' syntax to simplify declaration...
  nativeBuildInputs = with pkgs; [
    # ...this is the same as 'pkgs.cowsay'
    cowsay
  ];
}

Accessing the new shell now grants us access to cowsay, and exitting it removes it, neat.

$ nix-shell
## <snip>

[nix-shell]$ echo "Hi" | cowsay
 ____ 
< Hi >
 ---- 
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

[nix-shell]$ exit
exit

$ echo "Hi" | cowsay
bash: cowsay: command not found

That's about it for the basics of Nix shells.

✨ you've done it! go you!!!!

You should read the official documentation and familiarize yourself with the other aspects and little tricks available to you.

💥 going above and beyond

For the curious, here's our shell.nix file for the rt 42 project. have fun :)

{
  pkgs ? import <nixpkgs> { }
}:

let
  stdenv = pkgs.stdenvAdapters.useMoldLinker pkgs.clang17Stdenv;
in
  (pkgs.mkShell.override { inherit stdenv; }) {
    buildInputs = with pkgs; [
      SDL2
      readline
      vulkan-headers
      vulkan-loader
      vulkan-tools
    ];

    LD_LIBRARY_PATH="${pkgs.vulkan-loader}/lib";

    nativeBuildInputs = with pkgs; [
      norminette
      valgrind
      gdb
    ];
  }