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
:
|
|
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:
|
|
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:
|
|
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:
|
|
Is performing multiple things all at once. Specifically:
- Calls
import
with nixpkgs path and evaluates the expression fromdefault.nix
from that path. - The expression from
default.nix
returns a lambda. - This lambda is called with
{ config = {}; overlays = []; }
attribute set as an argument. - The result of the lambda call is assigned to
pkgs
. 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
:
|
|
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:
|
|
In fact, you don’t need to download the nix tarball so, the most basic shell.nix
might as well be:
|
|
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:
|
|
… and add new channels using:
|
|
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:
|
|
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 .envrc
3:
|
|
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:
|
|
This will create the most basic flake.nix
using the default template. You
can see the list of available templates using:
|
|
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:
|
|
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:
|
|
You can now enter the environment with:
|
|
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
:
|
|
Flake templates
Let’s explore built-in templates first.
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
As advised in nix pills, I’m gonna create the following build.sh
:
|
|
And the nix file for the project:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
nix as package manager
With nix-env
you can use nix
as your package manager (in addition to your system’s package manager).
|
|
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:
|
|
The version is fixed. Sure, you can override the URL but, I wouldn’t recommend that. Here’s why:
|
|
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:
|
|
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:
|
|
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:
|
|
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
:
|
|
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:
|
|
Scripts just form a new command in the environment so, can be called directly:
|
|
On the other hand, tasks are invoked via devenv:
|
|
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:
|
|
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:
|
|
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