How to use Nix Flakes to Manage C++ Libraries and Header Files
Let’s explore how you configure a nix flake to handle installing C++ developer libraries automatically. This works the same way as how a flake can install the developer tools for you, be it the gcc compiler or node.js, and applications such as MySQL. Normally on traditional distros such as Ubuntu, the libraries and headers, as well as apps such as MySQL, live in a shared area, which introduces problems with versions. This could cause conflict between different projects that need different versions. Nix solves this problem beautifully by installing whatever version it needs for your particular project when you simply type
nix develop
We’re assuming you’re at least vaguely familiar with nix flakes, and that you’re at least an entry-level C++ coder.
How C++ Handles Libraries
Let’s start with a very basic C++ program and talk about what happens when you compile and link this code in a Debian-based distro (without using Nix).
First, here’s the app, just a Hello World app; save this in some new folder with the filename hello.cpp:
#include <iostream>
using namespace std;
int main() {
cout << "Hello world!" << endl;
}
This code includes the iostream library, adds in the std namespace, and then has a main() which prints out “Hello World” by passing it to the cout function, followed by a call to the endl function. (And yes, cout and endl are functions, not keywords.)
I’m assuming you’ve already installed the g++ tools; if not, it’s a quick fix by typing:
sudo apt install build-essential
Then you can build the app like so:
g++ hello.cpp -o hello
This compiles and links the code to build an executable file called hello, which you can see from the `ls -l` command:
ls -l
total 24
-rwxr-xr-x 1 user user 16504 Jun 27 17:48 hello
-rw-r--r-- 1 user user 95 Jun 27 17:42 hello.cpp
And, of course, you can run the app by typing:
./hello
which prints out:
Hello world!
Let’s go over what happened when we built the code.
When you run g++ on a C++ source file, the toolchain goes through several steps to compile and link your program. The first stage is the preprocessor, and from there the code gets translated to assembly code.
The next step is important: If there are multiple .cpp files in your application, they all get “linked” together into a single executable file. But there are also likely external library files that contain compiled code that your app is calling into, such as the `cout` and `endl` stuff found in iostream. The entire iostream library does not get linked into your app. Instead, the code continues to live in a separate file called a shared library. When your app runs, a system component called the dynamic linker loads any shared libraries into memory and connects your program’s function calls to the actual code inside them.
You can find out what libraries the app needs by typing:
ldd hello
Which will produce output similar to this:
linux-vdso.so.1 (0x00007ffc975f7000)
libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x0000737681c00000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000737681800000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x0000737681ee5000)
/lib64/ld-linux-x86-64.so.2 (0x0000737681fda000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x0000737681eb7000)
This tells you the name and location of every library the compiled app needs to link to at runtime.
Because I didn’t use any non-standard libraries, only the built-in C++ libraries, the g++ tool is able to detect which libraries it needs to link to. But what about other libraries? Let’s take a quick look at that.
Using Additional Libraries
Let’s write an app that uses two additional libraries, both available through the apt package manager. Install them by typing the following:
sudo apt update
sudo apt install libcurl4-openssl-dev uuid-dev
This will install two libraries, libcurl (for making HTTP requests) and libuuid (for generating UUIDs). Specifically it installs the shared libraries and the header files.
Go ahead and create a new code file called uuid_curl.cpp and add the following code to it:
#include <iostream>
#include <curl/curl.h>
#include <uuid/uuid.h>
int main() {
// Generate a UUID
uuid_t uuid;
char uuid_str[37]; // UUIDs are 36 chars + null terminator
uuid_generate(uuid);
uuid_unparse(uuid, uuid_str);
std::cout << "Generated UUID: " << uuid_str << std::endl;
// Use libcurl to fetch a simple web page
CURL *curl = curl_easy_init();
if (curl) {
curl_easy_setopt(curl, CURLOPT_URL, "https://example.com");
CURLcode res = curl_easy_perform(curl);
if (res != CURLE_OK) {
std::cerr << "curl_easy_perform() failed: " << curl_easy_strerror(res) << std::endl;
}
curl_easy_cleanup(curl);
} else {
std::cerr << "Failed to initialize libcurl" << std::endl;
}
return 0;
}
For third-party libraries such as this, we need to update our g++ command to include them. If you try running the earlier g++ command, you’ll get some errors about “undefined reference”. So instead, let’s include the libraries in our command:
g++ uuid_curl.cpp -o uuid_curl -lcurl -luuid
You can run the compiled executable if you like:
./uuid_curl
And you’ll see a line print out with the generated UUID:
Generated UUID: 8413889a-687b-48af-a752-228645773ca9
followed by the HTML source found at www.example.com (which we won’t reproduce here as it’s not particularly interesting).
But what is interesting is the ldd output; start by typing:
ldd uuid_curl
And you’ll see a long output of many more libraries linked in. We won’t list them all here, but some include:
linux-vdso.so.1 (0x00007fffcfd96000)
libcurl.so.4 => /lib/x86_64-linux-gnu/libcurl.so.4 (0x0000785fbc0b9000)
libuuid.so.1 => /lib/x86_64-linux-gnu/libuuid.so.1 (0x0000785fbc0af000)
libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6 (0x0000785fbbe00000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000785fbba00000)
libnghttp2.so.14 => /lib/x86_64-linux-gnu/libnghttp2.so.14 (0x0000785fbc084000)
... (and several more)
Where are these libraries located?
This is where Nix becomes interesting, because Nix doesn’t maintain a central repository of libraries. Instead, the preferred way is to load them in when you enter a developer shell. But first let’s take a brief look at where they live on a system such as Debian.
For Debian-based systems (including Ubuntu) the libraries get installed in
/lib/x86_64-linux-gnu/
The header files generally get installed here:
/usr/include
(or at least in various subdirectories under this one). For example, iostream gets installed here:
/usr/include/c++/13/iostream
and curl gets installed here:
/usr/include/x86_64-linux-gnu/curl
and uuid gets installed here:
/usr/include/uuid/uuid.h
Ready for Nix
Great! Let’s see how to configure a Nix-based system to load the libraries.
In a system such as Ubuntu, we have system libraries and header files in places such as:
First, what happens if you launch a NixOS distro and look at the two locations mentioned earlier for the libraries and header files? You won’t find much at all. Here’s an example. (I’m running NixOS under WSL on Linux, hence the microsoft-named files). Here I’m looking at the contents of /lib and /usr/include:
$ls /lib
ld-linux.so.2 modules
$ ls /usr/include
ls: cannot access '/usr/include': No such file or directory
$ ls /lib/modules
5.15.167.4-microsoft-standard-WSL2 6.6.87.1-microsoft-standard-WSL2 6.6.87.2-microsoft-standard-WSL2
We see almost nothing. That’s because Nix doesn’t use global system paths for development like other distros do. Instead, libraries, headers, and tools live in isolated locations and should be loaded specifically for the development environment you’re working within. (Nix operates on one of many mantras, and in this case the mantra is “No Hidden Dependencies.”)
Additionally, by default, NixOS doesn’t come with g++ installed.
But don’t install it! Don’t run the command earlier that installs g++.
And this is where reproducibility comes in: The same inputs give the same outputs on any machine. So let’s build a flake that sets us up with the tools we need to build a simple C++ app that only uses iostream.
First, here’s the folder structure we’ll create:
my-cpp-flake/
├── flake.nix
├── src/
└── main.cpp
Remember the core idea behind Nix: We can enter a `nix develop` shell, and all our needed tools will download (if need be) into the store and activate for our current session, all without us needing to manually install everything. Are we working on a python project? Great! The python tools (including python and pip) will be there. A node.js app? Cool! The node tools (such as node, python, tsc, and so on will be there), all installed and made available via the system path when you enter a nix developer shell.
So after you’ve set up the above directory structure, let’s create a barebones, very simple main.cpp:
#include <iostream>
int main() {
std::cout << "Hello world!" << std::endl;
return 0;
}
Seems simple enough, BUT: With what we said earlier, let’s not forget that this app requires two things:
- The iostream header file
- The libraries to link to
As we saw earlier, neither are present by default on Nix.
That means, our next step is to create a Nix Flake that installs these items (that is, downloads and makes available anytime we switch to this particular development!) and makes them available to our build.
So let’s build our flake.nix file that creates a derivative with the above!
{
description = "Modern C++ flake with src/ layout";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in {
# Dev shell for manual compilation
devShells.default = pkgs.mkShell {
packages = [ pkgs.gcc pkgs.gnumake ];
};
# Nix build: compiles src/main.cpp and installs to $out/bin/
packages.default = pkgs.stdenv.mkDerivation {
pname = "hello-cpp";
version = "1.0";
src = ./.;
buildPhase = ''
mkdir -p build
g++ src/main.cpp -o build/hello
'';
installPhase = ''
mkdir -p $out/bin
cp build/hello $out/bin/
'';
};
});
}
IMPORTANT! We’re assuming you have Nix configured to use flakes. (Everybody does, right?)
Now you’re ready to build. You don’t really even need to be in a Nix shell; Nix flakes include a build option; simply type:
nix build
After a moment, you’ll have a result folder, and in that a bin folder. Inside the bin will be a file called hello, with its executable bit set. Go ahead and run it:
[nixos@nixos:~/dev/freckleface/my-cpp-flake]$ ./result/bin/hello
Hello world!
It worked! Now let’s see where the libraries are by using ldd again:
$ ldd result/bin/hello
linux-vdso.so.1 (0x00007ffff7fc6000)
libstdc++.so.6 => /nix/store/90yn7340r8yab8kxpb0p7y0c9j3snjam-gcc-13.2.0-lib/lib/libstdc++.so.6 (0x00007ffff7c00000)
libm.so.6 => /nix/store/pf5avvvl4ssd6kylcvg2g23hcjp71h19-glibc-2.39-52/lib/libm.so.6 (0x00007ffff7edd000)
libgcc_s.so.1 => /nix/store/90yn7340r8yab8kxpb0p7y0c9j3snjam-gcc-13.2.0-lib/lib/libgcc_s.so.1 (0x00007ffff7eb8000)
libc.so.6 => /nix/store/pf5avvvl4ssd6kylcvg2g23hcjp71h19-glibc-2.39-52/lib/libc.so.6 (0x00007ffff7a13000)
/nix/store/pf5avvvl4ssd6kylcvg2g23hcjp71h19-glibc-2.39-52/lib/ld-linux-x86-64.so.2 => /nix/store/pf5avvvl4ssd6kylcvg2g23hcjp71h19-glibc-2.39-52/lib64/ld-linux-x86-64.so.2 (0x00007ffff7fc8000)
They’re all in the nix store. And in the nix world, that’s where they should be.
Tip: If you’re curious about these library files getting duplicated, fear not. If you create another C++ project in another folder with its own flake.nix file and build it, and then when you run ldd, you’ll see the same store locations for the libraries. Nix is smart enough when it installs files that if they’re identical, it doesn’t create a new copy, but rather simply grabs the existing one.
Now let’s add in the two libraries we used earlier, curl and uuid. Update your flake.nix file to look like this; I’ve highlighted the additional lines with comments:
{
description = "Modern C++ flake with src/ layout, libcurl and libuuid";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in {
devShells.default = pkgs.mkShell {
packages = [
pkgs.gcc
pkgs.gnumake
pkgs.curl # Add this line
pkgs.libuuid # Add this line
];
};
packages.default = pkgs.stdenv.mkDerivation {
pname = "hello-cpp";
version = "1.0";
src = pkgs.lib.cleanSource ./.;
buildInputs = [ # Add this line
pkgs.curl # Add this line
pkgs.libuuid # Add this line
]; # Add this line
buildPhase = ''
mkdir -p build
g++ src/main.cpp -o build/hello -lcurl -luuid # Add these two params
'';
installPhase = ''
mkdir -p $out/bin
cp build/hello $out/bin/
'';
};
});
}
Use the same source code as earlier with curl and uuid. Build it again. When you run it, you should see the same output (a UUID and the source of example.com.
Try running ldd this time; you’ll see several lines, but of note are these two:
libcurl.so.4 => /nix/store/fv1plv85qylnw87dlnxaqkgl06drgr1r-curl-8.7.1/lib/libcurl.so.4 (0x00007ffff7ef9000)
libuuid.so.1 => /nix/store/ixlnf1frsa2df5jjm82n0gs8h3wi6lby-util-linux-minimal-2.39.4-lib/lib/libuuid.so.1 (0x00007ffff7eef000)
Nix found them online and installed them for you.
That’s it!
What about distribution
This is where things differ a bit more from more traditional distros.
The general idea is that the user you’re delivering your software to build it as well. And it’s easy for them to do so, which gets to the heart of why Nix is so great: The end user doesn’t need to fuss with installing a bunch of developer tools. All they need to do is pull the repo, and type
nix build
just like the developer did. That’s it; that’s how you distribute software.
But if you’re building a larger app, an alternative approach is to build it and make it available in the search.nixos.org. (We won’t go into the details here, as that’s a topic for another blog post.)
Conclusion
Thanks to Nix’s unique approach to software installation and development, it’s incredibly easy for someone to reproduce exactly what the developer built — all from a simple flake.nix file and one command. If you’re used to shipping software via installer files or traditional package managers, this might feel a little different at first. But once you get the hang of it, it works beautifully — it’s the Nix way.