One of the most tedious and time-wasting parts of the development process is setting up tooling. For a NodeJS project this requires getting the right Node version, getting the preferred package manager, installing things like a linter, formatter, and sometimes a compiler for TypeScript or other JS-transpiled languages. Well today we are going to talk about using Nix as an SDK/tool manager, and how we can setup easy, reproducible, and ci-compatible environments.
Installing Nix
There are two ways that I would recommend installing the Nix package manager:
- Using the official installer on the Nix download page.
- Using the Determinate nix installer that is on GitHub.
We'll use option 2, as it's the fastest and easiest method and also tells you everything it's going to do before it installs it to your system. To get started we'll want to run the following command:
$ curl -sL https://install.determinate.systems/nix | sh -s -- install
This will give you the following prompt asking if you want to install "Determinate Nix," which is primarily meant for enterprise setups, and thus we will not be needing it, so we can reply no.
Install Determinate Nix?
Selecting 'no' will install Nix from NixOS, without automated garbage collection and enterprise certificate support.
Proceed? ([Y]es/[n]o/[e]xplain):
After responding no to installing determinate nix it will give you an install plan and ask if you want to continue, assuming everything looks good, type "yes" to continue and it will install Nix for you.
Nix install plan (v3.0.0)
Planner: linux (with default settings)
Planned actions:
* Create directory `/nix`
* Fetch `https://releases.nixos.org/nix/nix-2.26.2/nix-2.26.2-x86_64-linux.tar.xz` to `/nix/temp-install-dir`
* Create a directory tree in `/nix`
* Synchronize /nix/var ownership
* Move the downloaded Nix into `/nix`
* Synchronize /nix/store ownership
* Create build users (UID 30001-30032) and group (GID 30000)
* Setup the default Nix profile
* Place the Nix configuration in `/etc/nix/nix.conf`
* Configure the shell profiles
* Configure upstream Nix daemon service
* Remove directory `/nix/temp-install-dir`
Proceed? ([Y]es/[n]o/[e]xplain):
You should now have Nix installed. Next, we'll configure some features that are not enabled by default as they're considered still experimental.
Configuring Features
We need to enable two key features: nix-command
and flakes
. The nix-command
feature provides a more user-friendly CLI compared to Nix's default commands. Meanwhile, the flakes
feature allows for lockfile-backed reproducible packages and environments, making it easier to share setups.
To do this we will want to create a directory and edit a config file, but this part is easy to do.
$ mkdir -p $HOME/.config/nix
$ echo "experimental-commands = nix-command flakes" >> $HOME/.config/nix/nix.conf
After this has been done, you will have both of the features we need to get things setup.
Setting Up a Flake
When using Nix we use something called Flakes, as described on the Nix wiki:
Nix flakes enforce a uniform structure for Nix projects, pin versions of their dependencies in a lock file, and make it more convenient to write reproducible Nix expressions.
In other words, a reproducible setup with pinned dependencies for environments and packages. We'll configure a flake to define our development environment, ensuring consistency. To achieve this, we'll create a flake.nix
file with the following structure:
{
inputs = {
utils.url = "github:numtide/flake-utils/main";
};
outputs = { self, nixpkgs, utils }:
utils.lib.eachDefaultSystem(system:
let pkgs = import nixpkgs { inherit system; }; in {
devShells.default = pkgs.mkShell {};
});
}
This setup is quite minimal and doesnโt yet include the necessary packages or tools for our project. Its primary purpose is to enable the nix develop
command, which lets us enter a reproducible shell environment. In this guide, we'll focus on two key fields: buildInputs
and nativeBuildInputs
. While they may sound similar, they serve distinct purposes.
Run-time Packages (buildInputs
)
The buildInputs field should be used for packages that are needed at run-time. Think things like C libraries, and tooling that your program might use while the user is using it. It should note these will also be built for the target architecture rather than the one you are building from.
Build-time Packages (nativeBuildInputs
)
The nativeBuildInputs field should be used for packages that are needed at build-time. This would typically be things like your C compiler, formatter, linter, and other things like that. Generally if your project doesn't need it when it's running, it should be put in this field rather than buildInputs.
Environment Variables
Setting environment variables in your shell is straightforward. Just define them within the shell section of your flake. For example, if you need to set GOPATH
, you can specify it directly inside the mkShell {}
block, and it will work seamlessly. Once you get the hang of it, using this approach becomes second nature!
An Example of a Flake
So, with the information that we have thus learned we will put it together and make a flake for an example GLFW + C project and make a flake that utilizes these things, I will provide comments in the example code to explain why things are where they are and what they are doing.
{
inputs = {
utils.url = "github:numtide/flake-utils/main";
};
outputs = { self, nixpkgs, utils }:
utils.lib.eachDefaultSystem(system:
let pkgs = import nixpkgs { inherit system; }; in {
devShells.default = pkgs.mkShell {
# This is setting the "ENV" environment variable
# in our shell, if we echo the value in the dev
# shell it will simply print out "production."
ENV = "production";
# We put GLFW into the buildInputs field as it is
# needed at runtime, our program depends on it to
# work and run correctly when users use it.
buildInputs = with pkgs; [ glfw3 ];
# We put clang, pkg-config, and meson in this field instead
# as they are only really needed at build-time, the user is
# not going to be interfacing with these tools themselves.
nativeBuildInputs = with pkgs; [ clang pkg-config meson ];
};
});
}
The with pkgs part of these declarations tell nix to use the pkgs object defined above for the list that we specify after it, in other-words doing this allows us to just enter the name of the package instead of having to type pkgs.clang, etc. for each item, thus making our code a lot cleaner.
Continuous Integration
One of the benefits of using a Nix flake is that if your CI setup has Nix installed you can easily use the exact same environment that you are using locally to develop your app or website. For those using Actions (GitHub, Gitea, and Forgejo) you can simply use an action such as Install Nix to get Nix installed into your CI workflow quick and easily, and from there building your app is simply:
$ nix develop -c "meson build"
Though if you are going to be using Nix to build for production I recommend reading the wiki page on flakes and learning how to create a package instead, as then it's as simple as doing nix build
and it'll build everything itself with no user intervention, including running tests and any patches.
Final Words
Nix is very complex and has many features and things you can can configure and setup, it would take me hours upon days to try to explain everything you can do with flakes let alone Nix itself. I highly recommend reading through the documentation, the wiki, and experimenting with things yourself. It can be annoying to mess with at first but once you get the hang of it, it is really nice tool to have in your shed. I've used many tools but none quite work just as well as Nix does.
Top comments (0)