Contents

Nix is to C++ what uv/poetry is to Python

The topic of dependencies management is coming back and again. nix and nixpkgs being a rich repository, provides a convenient way to manage both project dependencies and related tooling in a reproducible way. This is something I’m finding more and more useful as a fundamental development tool. nix is quite complex and some exploration is needed to wrap your head around it. In this post, I’m going through most basic use cases as well as describe some basics of nix language itself - mainly for my own reference but hopefully useful to others as well.

Post installation essentials

After installing nix, I’d recommend to add the following to $HOME/.config/nix/nix.conf:

1
experimental-features = nix-command flakes

This enable the nix <subcommand> command and support for flakes. These features are quite mature but still experimental.

nix-shell

nix-shell is the most basic way to create development environments. nix-shell allows for creation of a shell environment with a specific set of variables and dependencies available only to that environment. The environment is ephemeral - everything is gone once you exit it.

Here’s the most basic usage:

nix-shell -p gcc gdb valgrind

After a while the shell becomes ready and all the requested packages are available in the session:

1
2
3
4
[nix-shell:/usr/src]$ which valgrind gcc gdb
/nix/store/6aas3c4wbmm1d4lcm9756kmgp263hzyl-valgrind-3.25.1/bin/valgrind
/nix/store/pbqah1qk4b5y14fqinr1h8zvhqy71v81-gcc-wrapper-14.3.0/bin/gcc
/nix/store/30ny2x1zhcl6k65nmw6bqn9z7mrk0afy-gdb-16.3/bin/gdb

I want to manage the dependencies in a declarative way which is possible using shell.nix file. shell.nix is automatically loaded, if present, on nix-shell invocation with no arguments.

nix language fundamentals

nix is using its own language1. I don’t like to use things which I don’t understand so, a basic acquaintance with the syntax is a must.

Most basic shell.nix will look the following way:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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
  ];
}

Looks scary but there’s only a handful of constructs used here that must be understood. Specifically:

  • let expression
  • function invocation (or application in functional programming parlance)
  • attribute sets
  • with expression
  • list declaration
  • variable assignment
  • derivation

let expressions define a set of variables (within the let block) that can be referenced within the in block later on. Later, we’ve got a fetchTarball function invocation with the URL as an argument. This downloads the tarball from the given URL and returns a path to the unpacked tree. Next, another function application: import. import loads and evaluates nix expression from the default.nix file from the given path. import takes only one argument, so the line:

1
  pkgs = import nixpkgs { config = {}; overlays = []; };

Is performing multiple things all at once. Specifically:

  1. Calls import with nixpkgs path and evaluates the expression from default.nix from that path.
  2. The expression from default.nix returns a lambda.
  3. This lambda is called with { config = {}; overlays = []; } attribute set as an argument.
  4. The result of the lambda call is assigned to pkgs.
  5. pkgs is a complex attribute set which is described in details in its own reference manual2

Finally, there’s pkgs.mkShellNoCC which is just another function application (again, with an attribute set as an argument). This final application returns something called a derivation. In short, a derivation is a construct describing in details the build steps to “realise” (another nix specific term) the build outputs. Within the attribute set, being an argument to mkShellNoCC there’s the with expression; it’s the simplest one to explain - it’s just really a syntactic sugar allowing for more convenient access to pkgs attributes without having to use pkgs (or any other attribute set, for that matter) all the time.

You can experiment with all of that yourself using nix repl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ nix repl
Nix 2.30.1
Type :? for help.
nix-repl> nixpkgs = fetchTarball "https://github.com/NixOS/nixpkgs/tarball/nixos-24.05"

nix-repl> pkgs = import nixpkgs {}

nix-repl> shell_deriv = pkgs.mkShell { packages = [ pkgs.lolcat ]; }

nix-repl> shell_deriv
«derivation /nix/store/bnhsa14x1zsd845kxpk7jf27i18gcaww-nix-shell.drv»

nix-repl> :b shell_deriv

This derivation produced the following outputs:
  out -> /nix/store/7fxgg32pqfymcbjj0y7svbgr9s7yj2vj-nix-shell

Notice that absolutely nothing happened until I’ve requested to build the derivation in the last line. nix is lazily evaluated.

Lookup paths

One thing which you’ll often find are so called lookup paths. These are defined with the following syntax:

<nixpkgs/some/other/path>

As the documentation describes, the first component (the prefix) is picked from nix builtins. The result forms a path. Here’s how it looks in REPL:

1
2
3
4
5
6
7
8
$ nix repl
Nix 2.30.1
Type :? for help.
nix-repl> <nixpkgs>
/nix/store/7djj8mamb8ms7ig203m9ckrc3sz0myb8-nixpkgs/nixpkgs

nix-repl> <nixpkgs/doc>
/nix/store/7djj8mamb8ms7ig203m9ckrc3sz0myb8-nixpkgs/nixpkgs/doc

In fact, you don’t need to download the nix tarball so, the most basic shell.nix might as well be:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
let
  pkgs = import <nixpkgs> {};
in

pkgs.mkShell {
  packages = with pkgs; [
    gcc
    valgrind
    neovim
    gdb
    cmake
    ninja

  ];
}

This would be a good list defining a typical C++ development environment which now can be entered with a single nix-shell… and that’s it.

Searching for packages

nix organises it’s packages repositories via channels. You can list configured channels using:

1
2
$ nix-channel --list
nixpkgs https://nixos.org/channels/nixpkgs-unstable

… and add new channels using:

1
2
$ nix-channel --add https://nixos.org/channels/nixos-25.05 nixos2505
$ nix-channel --update

A good starting point for available channels is on nix wiki.

Having channels configured it’s possible to search the contents from the shell itself:

1
2
nix search nixpkgs valgrind
...

direnv

Executing nix-shell everytime you want to enter your development environment in all your shell sessions can be annoying. Thankfully, direnv provides integration out of the box. Just the following to your .envrc3:

1
use nix

After that, most likely, you’ll need to allow the .envrc using direnv allow and that’s it. You’ll enter into nix-shell by default when entering project’s root.

Flakes?

nix flakes is the revised approach to reproducible build environments. Flakes are file system locations containing flakes.nix file describing steps to produce a given package with its associated dependencies and build environment.

The main advantage over nix-shell is that with flakes you get flake.lock - which defines exact versions of packages required. Something which is not available via pure nix-shell. This is important because if you’re using e.g. nixpkgs-unstable channel, then the versions are not guaranteed.

Getting started

Let’s just fire off with:

1
nix flake init

This will create the most basic flake.nix using the default template. You can see the list of available templates using:

1
nix flake show templates

It’s possible to create your own templates as well. I’ll come back to that later on. nixos wiki describes the flake.nix schema well so, it’s useful to have it handy when looking at the newly created file. For me, it looks the following way:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  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;

  };
}

Just as a short recap, the file defines a global attribute set. It has the description attribute and, most importantly, inputs - being a collection of dependencies to the outputs lambda. The outputs lambda takes the attribute set as the only argument. The first field - self refers to current flakes’s outputs attribute (it’s just a recursive reference), the remaining arguments are “realised” (evaluated) inputs. To achieve similar functionality as with nix-shell, we need to define devShell attribute. I’m gonna rewrite the file the following way:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
  description = "flake as dev environment example";

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

  outputs = { self, nixpkgs }:
    let
        # --impure needed
        # system = builtins.currentSystem;
        system = "x86_64-linux";
        pkgs = import nixpkgs { inherit system; };
    in {
        devShell.${system} = with pkgs; mkShell {
            packages = [
                lolcat
            ];
        };
  };
}

You can now enter the environment with:

1
nix develop

We’re in! You may have noticed that this created the flake.lock file along the way as well. Ad hoc environments (similar to nix-shell -p) can be created like so:

nix shell nixpkgs#hello

direnv

Similarly, as with shell.nix, direnv provides integration for flakes using the following in .envrc:

1
use flake

Flake templates

Let’s explore built-in templates first.

1
2
3
4
5
$ nix flake init -t templates#c-hello
wrote: "/usr/src/ctmpl/Makefile.in"
wrote: "/usr/src/ctmpl/configure.ac"
wrote: "/usr/src/ctmpl/flake.nix"
wrote: "/usr/src/ctmpl/hello.c"

This generates the whole project skeleton. nix templates are for the entire flake (which is a tree, not only the flake.nix file). Okay, but how to create your own template? Templates are flakes as well so, a separate tree containing a git repository is needed:

1
2
3
4
5
6
twdev_tmpl/
|-- basic
|   `-- flake.nix
`-- flake.nix

2 directories, 2 files

Here, I’ve got only one template called basic and it’s just the flake.nix that I’ve already showed before. The interesting bit is the twdev_tmpl/flake.nix which contains the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  description = "twdev project templates";

  outputs = { self }: {
    templates = {
      basic = {
        path = ./basic;
        description = "Typical empty project skeleton";
      };
    };
  };
}

Of course, you’d store this flake either on gitlab or github (or within your own environment) and use it from there but it’s perfectly possible to refer to it using local paths. Here’s how to use the template:

1
nix flake init -t ./twdev_tmpl#basic

Derivations

If you’ve played around with the templates, especially c-hello or go-hello, you may have noticed that the generated flake.nix is using the underlying build system (automake for C and golang toolchain) to build the flake. That’s, in short, what a derivation is: a nix build recipe for the project. The best thing about derivations is that you can chain them! The output (build results) of one derivation can be an input to another! Let’s try to explore that from a practical perspective.

derivatons are in details described in nix pills. I invite you to read that material as this is a great introduction. I’m gonna cut on the details. Let’s create a toy CMake project first:

1
2
3
4
5
6
$ tree
.
├── cmake_hello.cpp
└── CMakeLists.txt

1 directory, 2 files

As advised in nix pills, I’m gonna create the following build.sh:

1
2
3
4
5
set -e
export PATH="${cmake}/bin:${coreutils}/bin:${ninja}/bin:${gcc}/bin"
cmake -Bbld -DCMAKE_INSTALL_PREFIX=${out} -GNinja ${src}
cmake --build bld
cmake --install bld

And the nix file for the project:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
let
    pkgs = import <nixpkgs> {};
in
derivation {
    name = "cmake_hello";
    system = builtins.currentSystem;
    builder = "${pkgs.bash}/bin/bash";
    inherit (pkgs)
        cmake
        coreutils
        ninja
        gcc
        ;
    args = [ ./build.sh ];
    src = ./.;
}

This simply pulls some utilities needed for the build and executes the builder (bash) with build.sh as an argument. The package can be built like so:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
$ nix-build cmake_hello.nix
this derivation will be built:
  /nix/store/dvbmhjn2n17n1g4vsfrjd35j9cxazps8-cmake_hello.drv
this path will be fetched (0.15 MiB download, 0.50 MiB unpacked):
  /nix/store/b8hida4z1qy9aw1fy3vdr251rzf125i0-ninja-1.12.1
copying path '/nix/store/b8hida4z1qy9aw1fy3vdr251rzf125i0-ninja-1.12.1' from 'https://cache.nixos.org'...
building '/nix/store/dvbmhjn2n17n1g4vsfrjd35j9cxazps8-cmake_hello.drv'...
-- The CXX compiler identification is GNU 14.2.1
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /nix/store/gj9lra51hwhxnhz05jqk5lh03wipamv0-gcc-wrapper-14-20241116/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done (0.4s)
-- Generating done (0.0s)
-- Build files have been written to: /build/bld
[2/2] Linking CXX executable cmake_hellohello.dir/cmake_hello.cpp.o
-- Install configuration: ""
-- Installing: /nix/store/2fna4r1nr6i3ryvbsz6mqq824vdfb8mq-cmake_hello/bin/cmake_hello
/nix/store/2fna4r1nr6i3ryvbsz6mqq824vdfb8mq-cmake_hello

This is very manual though and, of course, there are already means to make the whole process simpler and less involving, one of which is stdenv.mkDerivation. The same project can be build using the following nix file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
let
    pkgs = import <nixpkgs> {};
in
pkgs.stdenv.mkDerivation {
    name = "cmake_hello";
    version = "0.0.1";
    src = ./.;
    nativeBuildInputs = with pkgs; [
        cmake
        ninja
        gcc
        coreutils
    ];
}

Building directly from github/gitlab

It’s even simpler to build dependencies hosted on gitlab or github (not only, nixpkgs supports a variety of different fetchers). Let’s use bzip2 as an example, which can be built using the following nix file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let
    pkgs = import <nixpkgs> {};
in with pkgs;
stdenv.mkDerivation {
    name = "bzip2";
    src = fetchFromGitHub {
        owner = "libarchive";
        repo = "bzip2";
        rev = "bzip2-1.0.6";
        hash = "sha256-Dr9TBONHYtayXJs30eMb2XgCtelgofBG6Pk5gKHBO3Y=";
    };
}

Building complex projects

Remember when I wrote about organising dependencies between projects in a complex stack? nix solves that problem entirely in an elegant way, allowing for seamless integration of projects having different build systems or different versions since it’s possible to express dependencies between derivations using the buildInputs attribute. Here’s a simple example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
let
	pkgs = import <nixpkgs> {};
	inherit (pkgs) stdenv;

	fmtd = stdenv.mkDerivation {
		name = "fmtd";
		src = pkgs.fetchFromGitHub {
			owner = "fmtlib";
			repo = "fmt";
			rev = "11.2.0";
			hash = "sha256-sAlU5L/olxQUYcv8euVYWTTB8TrVeQgXLHtXy8IMEnU=";
		};
		buildInputs = [
			pkgs.cmake
			pkgs.ninja
		];
	};

	cmake_hellod = stdenv.mkDerivation {
		name = "cmake_hello";
		version = "0.0.2";
		src = ./.;
		buildInputs = [
			fmtd
			pkgs.cmake
			pkgs.gcc
			pkgs.ninja
		];
	};
in
cmake_hellod

nix as package manager

With nix-env you can use nix as your package manager (in addition to your system’s package manager).

1
nix-env --install --attr devenv -f https://github.com/NixOS/nixpkgs/tarball/nixpkgs-unstable

The following will install devenv directly into your user’s environment (of course it physically lives in the nix store but your user environment will be extended with this new package).

Version pinning

This is where things are not so great, in my opinion. If you go to the repl and see what’s inside a derivation for… e.g. bash, you’ll notice the following:

1
2
3
4
5
6
7
$ nix repl
Nix 2.30.1
Type :? for help.
nix-repl> pkgs = import <nixpkgs> {}

nix-repl> pkgs.bash.src.url
"mirror://gnu/bash/bash-5.2.tar.gz"

The version is fixed. Sure, you can override the URL but, I wouldn’t recommend that. Here’s why:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
nix-repl> pkgs.bash.patches
[
  «derivation /nix/store/xfyar23wkyzj8q8n4sasx62jmk8qvij6-bash52-001.drv»
  «derivation /nix/store/yrahb3yc7d8sgxhdgnk53ik6cp3y8iaz-bash52-002.drv»
    ...
  «derivation /nix/store/bdxdfrq31vldmx86g6a3cblmq4m6g7fi-bash52-036.drv»
  «derivation /nix/store/4y4gfgibnf88qvbwqq7kis0ryv25liqq-bash52-037.drv»
  /nix/store/rqa7rpgmr5zic5gmfs03hgcd6jv809lw-nixpkgs/nixpkgs/pkgs/shells/bash/pgrp-pipe-5.patch
  /nix/store/rqa7rpgmr5zic5gmfs03hgcd6jv809lw-nixpkgs/nixpkgs/pkgs/shells/bash/parallel.patch
  /nix/store/rqa7rpgmr5zic5gmfs03hgcd6jv809lw-nixpkgs/nixpkgs/pkgs/shells/bash/fix-pop-var-context-error.patch

Yeah, there’s a ton of patches. If you’re lucky a newer version might work but in general, there’s no guarantee at all. Right, so what are the options?

Derivation overrides

Nixpkgs provide a convenient function to override derivation attributes. Here’s one usage example and… a build failure due to incompatible patches:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
nix-repl> bash529 = pkgs.bash.overrideAttrs { src = pkgs.fetchurl { url = "https://ftp.gnu.org/gnu/bash/bash-5.2.9.tar.gz"; hash = "sha256-aNl4JkJTvJM9aS8d4ZXi5bRjo5hN+05VBLB2hl8Wtt0="; }; paches = []; 
nix-repl> :b bash529
error: Cannot build '/nix/store/9fy87gx8793n67l3sfidd5ns3dx7s6cd-bash-interactive-5.2p37.drv'.
       Reason: builder failed with exit code 1.
       Output paths:
         /nix/store/0fbcdcrw7gb0yxzkjval3y109hbmnkyr-bash-interactive-5.2p37-dev
         /nix/store/70rmjvm65rx3ljwiw9sbysmiq3hqz99s-bash-interactive-5.2p37-doc
         /nix/store/8nj0580xla8206xnjyg2rcl8xhpj6pmz-bash-interactive-5.2p37-debug
         /nix/store/f4ky64wvf6dbbcaslnry02rghbd042r9-bash-interactive-5.2p37
         /nix/store/f7s66lbcxiq17awi40f31rwgw839b580-bash-interactive-5.2p37-man
         /nix/store/lql9h01r9jdih28pi989vk1ak0wfil3q-bash-interactive-5.2p37-info
       Last 14 log lines:
       > Running phase: unpackPhase
       > unpacking source archive /nix/store/3lqn2yxyw5051vg8nk7989kvhdpysm1a-bash-5.2.9.tar.gz
       > source root is bash-5.2.9
       > setting SOURCE_DATE_EPOCH to timestamp 1667835111 of file "bash-5.2.9/patchlevel.h"
       > Running phase: patchPhase
       > applying patch /nix/store/a73wzcks7h2y814qxa1z3kv1hg205mpm-bash52-001
       > patching file subst.c
       > Reversed (or previously applied) patch detected!  Assume -R? [n]
       > Apply anyway? [n]
       > Skipping patch.
       > 1 out of 1 hunk ignored -- saving rejects to file subst.c.rej
       > patching file patchlevel.h
       > Hunk #1 FAILED at 26.
       > 1 out of 1 hunk FAILED -- saving rejects to file patchlevel.h.rej
       For full logs, run:
         nix-store -l /nix/store/9fy87gx8793n67l3sfidd5ns3dx7s6cd-bash-interactive-5.2p37.drv

Sometimes it works, if you’re lucky, but it’s not the best approach when it comes to version management. Here’s a successful override, as a counter example:

1
2
3
4
5
nix-repl> sed42 = pkgs.sedutil.overrideAttrs { name = "sed42"; src = pkgs.fetchurl { url = "https://ftp.gnu.org/gnu/sed/sed-4.2.tar.gz"; hash = "sha256-20XNY/0BDmUFN9ZdXfznaJplJ0UjZgbl5ceCk3Jn2Y
nix-repl> :b sed42

This derivation produced the following outputs:
  out -> /nix/store/faq33aqiy9w2jjvkh816xpzswmq2av7p-sed42

The overall experience is a bit of a hit and miss.

Using specific version of Nixpkgs

You probably recall, that when discussing nix-shell, I’ve just pulled the nixpkgs tarball from github:

1
nixpkgs = fetchTarball "https://github.com/NixOS/nixpkgs/tarball/nixos-24.05";

That’s your option. If you want different version - just use different snapshot. The obvious problem with this approach is that it dictates the entire set of versions of all packages you might be relying on. The solution to that is to fork it, and modify wherever needed. You can always use multiple sets of nixpkgs, each from different revision to satisfy all dependencies. The problem is that this approach will introduce a lot of bloat as all transitive dependencies of a given package version will be pulled in as well.

I guess it’s acceptable if we’re talking about a substantial software stack but when you just need two or three specific packages then this seems very inconvenient and a bit of an overkill.

There are sites created like nix package versions to make the search process more accessible.

3rd party tools

There are package managers abstracting nix and operating on top of it. They usually provide their own package repositories, often containing specific versions of certain packages.

Tooling

For the purpose of organising tooling within a development environment a set of environment managers (as I like to call them) have been created. The most prominent ones worth mentioning are:

All of these, create a project specific environment defined through configuration files. These environments come with specific set of shell variables, available tools, background processes etc. These tools undeniably provide convenience.

The main problem I’ve got with these is that they add another layer on top of nix, this sometimes may cause problems and can be more difficult if you try to do something non-standard that these tools didn’t anticipate by design. But, it’s worth to at least briefly go through them and evaluate how they work.

devenv

devenv truly makes me feel like I’m using python. You start a new project using:

devenv init <projectname>

This will bootstrap devenv.nix amongst other files. In there, you can add your dependencies, background processes you wish to run on entering the project, hooks, so on and so forth. Again, it very much feels like a native alternative to uv or poetry:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ devenv init cpp_megaproj
$ cd cpp_megaproj
... direnv activates devenv, which installs all tools declared in `packages` in devenv.nix
$ devenv info

# env
- DEVENV_DOTFILE: /home/tomasz/cpp_megaproj/.devenv
- DEVENV_PROFILE: /nix/store/2h50y0yarysribhgjwvisbc9fq42ih4k-devenv-profile
- DEVENV_ROOT: /home/tomasz/cpp_megaproj
- DEVENV_RUNTIME: /run/user/1000/devenv-c352d56
- DEVENV_STATE: /home/tomasz/cpp_megaproj/.devenv/state
- DEVENV_TASKS: [{"after":[],"before":[],"command":"/nix/store/wwfh0az7zxli3kypv4n0392div7cpmw5-devenv-enterShell","description":"Runs when entering the shell","exec_if_modified":[],"input":{},"name":"devenv:enterShell","status":null},{"after":[],"before":[],"command":null,"description":"Runs when entering the test environment","exec_if_modified":[],"input":{},"name":"devenv:enterTest","status":null},{"after":[],"before":[],"command":null,"description":"","exec_if_modified":[],"input":{},"name":"devenv:files","status":null}]
- GREET: devenv

# packages
- pre-commit-4.0.1
- clang-tools-19.1.7
- cmake-3.31.6
- ninja-1.12.1
- pkg-config-wrapper-0.29.2

# scripts
- hello /nix/store/6wzn16vpqqqk781xjwqvbgy1cgxb2wln-hello

# tasks
- devenv:enterShell: Runs when entering the shell (/nix/store/wwfh0az7zxli3kypv4n0392div7cpmw5-devenv-enterShell)
- devenv:enterTest: Runs when entering the test environment (no command)
- devenv:files:  (no command)

If you’re not using direnv you’ll have to enter the environment manually using devenv shell.

You can even manage the whole environment via AI as devenv provides MCP.

You manage the environment via devenv.nix using nixlang. The file is split into sections and well documented so, I’m not gonna go through it.

devenv allows for creation of scripts and tasks. The former allows for defining small utility scripts in your language of choice. The latter brings more structure when it comes to dependency management between individual tasks additionally, it’s possible to group tasks into namespaces and execute them in parallel. It may be an all in one replacement for tools like Taskfile and justfile as well!

Personally, I’d use scripts for things like migrations, project building and testing and tasks for something like batch processing within the project.

Here’s the most basic application of scripts and tasks:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  scripts.proj_setup.exec = "cmake -Bbld .";
  scripts.proj_build.exec = "cmake --build bld";
  scripts.proj_test.exec = "ctest --test-dir bld";
  scripts.proj_pack.exec = "cd bld; cpack";

  # ...

  tasks = {
    "proj:format".exec = "fd -t f -e cpp -e hpp -E bld -x ls -l {}";
  };

Scripts just form a new command in the environment so, can be called directly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
2096:hermod megaproj 0 (master #) $ proj_setup
-- The CXX compiler identification is GNU 14.2.1
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /nix/store/f0m6caffiykyvsjim9376a3hx2yj2ghj-gcc-wrapper-14.2.1.20250322/bin/g++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done (0.3s)
-- Generating done (0.0s)
-- Build files have been written to: /home/tomasz/devenv_test/megaproj/bld
2097:hermod megaproj 0 (master #) $ proj_build
[ 50%] Building CXX object CMakeFiles/cmake_hello.dir/cmake_hello.cpp.o
[100%] Linking CXX executable cmake_hello
[100%] Built target cmake_hello
2098:hermod megaproj 0 (master #) $ proj_pack
CPack: Create package using TGZ
CPack: Install projects
CPack: - Run preinstall target for: cmake_hello
CPack: - Install project: cmake_hello []
CPack: Create package
CPack: - package: /home/tomasz/devenv_test/megaproj/bld/cmake_hello--Linux.tar.gz generated.
2099:hermod megaproj 0 (master #) $

On the other hand, tasks are invoked via devenv:

1
$ devenv tasks run proj:format

devbox

devbox operates very similarly to devenv. It gives you this python-esque feel as well, if that makes any sense. Just as before, you initiate and do basic project management just the way you’d expect to:

1
2
3
$ mkdir megaproj && cd megaproj
$ devbox init .
$ devbox add ninja cmake fmt

You could use a template by calling:

devbox create --template go myproj

C++ gets no love though so, this wasn’t an option. Straight away you see a new file devbox.json. It uses nix under the hood but defines the project configuration using JSON in its own way:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
$ cat devbox.json
{
  "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.15.0/.schema/devbox.schema.json",
  "packages": [
    "fmt@latest",
    "ninja@latest",
    "cmake@latest"
  ],
  "shell": {
    "init_hook": [
      "echo 'Welcome to devbox!' > /dev/null"
    ],
    "scripts": {
      "test": [
        "echo \"Error: no test specified\" && exit 1"
      ]
    }
  }
}

This level of indirection, in general, is okay. As I mentioned, it sometimes may be problematic when you’re doing something non-standard. The layout is more familiar if you’re coming from npm world I guess. You’ve got your scripts and similarly as with devenv you can define your command wrappers there.

One difference is that you have to generate direnv configuration yourself using:

devbox generate direnv

It doesn’t do that automatically. I guess it’s a feature since additionally, it allows for generation of dockerfiles as well so, you’ve got a choice of how you want to package the environment for lack of a better word.

Overall a very solid and pleasant experience although I probably prefer devenv personally over devbox. The fact that the former uses nix itself to back its configuration file is something I’m more happy with.

lorri

lorri is based on a different principle than the other two I’ve mentioned. There’s a daemon monitoring your shell.nix files. This daemon automatically reloads your environments when it detects changes in shell.nix files. It keeps things closer to nix itself but not sure if having the daemon is justified. The environments usually don’t change that often. Anyway, let’s give it a go.

If you’re not using systemd or you’re in a container, you may need to kick off the daemon manually:

lorri daemon

Within your project you need to activate lorri integration (this creates a direnv file):

lorri init
direnv allow

That’s it! It will now keep an eye on your project’s shell.nix.

Personally, I’m not a fan of this approach so, must admit, that this is probably my least favourite tool.

niv and npins

Right of the bat, niv doesn’t seem to be no longer maintained and npins is its successor.

I’m just gonna briefly mention these tools here as I haven’t spent much time with them. From what I gathered, npins allows for managing different remote sources and pinning their specific versions to a hash. It’s meant to be used with nix-shell. It’s effectively superseded by flakes.

There’s a good introduction about to npins on nix.dev which explains it from practical standpoint.

Conclusion

This post is quite long and may be overwhelming. My intention was to make it complete so, it can be used as a reference. nix for certain is a great option when it comes for development environments, dependency management and reproducibility in general, the only major downside I see is that the entry barrier is quite high and the associated learning curve is quite steep as well.

Looking for help?

Most important resources are:

  • nix.dev - reference manual
  • nixpkgs - nix package collection reference manual
  • nixos wiki - wiki for NixOS - but not strictly
  • nix pills - in-depth tutorials
  • nur - nix user repository