Easy Development Environments

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 is 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 will be going with option 2, as it is (in my opinion) the fastest and easiest way to install Nix, as it 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 in your preferred terminal emulator: $ 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 on your system, the next thing we will do is configure some features that are not enabled by default as they're considered still experimental. Configuring Features There is two features we want to enable: nix-command and flakes. The nix-command feature enables a more user-friendly CLI as compared to the default commands that are available with Nix by default. On the other hand, the flakes feature enables us to get lockfile-backed reproducible packages and shells, that can also be shared with other people to get "portable" yet reproducible environments. 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, reproducible environments and packages with pinned dependencies; We will be setting up a flake for our development environment(s) to act as a reproducible setup. To do this we will want to create something like the following in a flake.nix file: { 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 by itself is very bare-bones and does not have any of the packages or tools that we will need for our project, the only thing this does is allows the nix develop cli command to work that allows us to enter a reproducible shell environment. There are two fields we will be focusing on in this guide: buildInputs and nativeBuildInputs, which sound similar but do very different things. 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

Mar 7, 2025 - 23:58
 0
Easy Development Environments

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 is two ways that I would recommend installing the Nix package manager:

  1. Using the official installer on the Nix download page.
  2. Using the Determinate nix installer that is on GitHub.

We will be going with option 2, as it is (in my opinion) the fastest and easiest way to install Nix, as it 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 in your preferred terminal emulator:

$ 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 on your system, the next thing we will do is configure some features that are not enabled by default as they're considered still experimental.

Configuring Features

There is two features we want to enable: nix-command and flakes. The nix-command feature enables a more user-friendly CLI as compared to the default commands that are available with Nix by default. On the other hand, the flakes feature enables us to get lockfile-backed reproducible packages and shells, that can also be shared with other people to get "portable" yet reproducible environments.

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, reproducible environments and packages with pinned dependencies; We will be setting up a flake for our development environment(s) to act as a reproducible setup. To do this we will want to create something like the following in a flake.nix file:

{
  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 by itself is very bare-bones and does not have any of the packages or tools that we will need for our project, the only thing this does is allows the nix develop cli command to work that allows us to enter a reproducible shell environment. There are two fields we will be focusing on in this guide: buildInputs and nativeBuildInputs, which sound similar but do very different things.

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.

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 {
          # 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.

Expanding on Flakes

This is not everything that flakes can do or can contain, going into detail about all of the functionality and features that are possible with flakes would take forever. Instead I recommend reading the wiki page on flakes to get a better grasp of them. My guide only covers the basics.

Final Words and Resources

A lot of people find Nix confusing, and it definitely can be at times, things are worded weirdly and the docs are honestly lacking in a lot of places. So hopefully this post can help provide people with a view of the usefulness of Nix and flakes. On top of this, if you are using GitHub or something that supports actions (such as Forgejo / Gitea), you can also use the Install Nix action to install Nix into your CI environment then use commands to build your project using the same exact environment that you are using locally. I really hope this guide helps you as much as learning Nix helped me!