Nix for Development Environments

An image of the Nix logo in binary lettering. From the NixOS GitHub.

What is Nix?

Defining Nix and its selling points has always been complicated for me, because it fundamentally does so many things. There's the package manager, the language, the operating system, there's flakes and the new and old CLIs, there's community projects like NixOS-WSL and nix-darwin, and so many more things.

With that said, I'll do my best to define it. Nix is a package manager, like brew, apt, or choco, built to be extremely reproducible. It builds packages from source, like Gentoo, but features a binary cache to speed up downloads. This is possible, because Nix is built on top of a functional language, which allows exact builds to be hashed and cached. Then flakes make this sort of thing easier, using a flake.lock (a lot like a package-lock.json for Node) to put all of the hashing, locking, etc.

Why Would I Use This?

The main selling point of Nix is that it's reproducible, so it gives a lot of the same promises that solutions like Docker and Ansible give, but with a bit more consistency.

However, for today's blog post, I'll only be talking about one use for Nix: Reproducible Development Environments. Think of this as your operating system's IDE, which provides you with everything you'll need to write <xyz>, all accessible via one command.

Setup

First, you'll need to install Nix.

Windows (through WSL)

Make sure that you've enabled WSL on Windows and rebooted.

Then open up your Terminal or PowerShell, and run the following command:

$ sh <(curl -L https://nixos.org/nix/install) --daemon

Then just follow the steps provided!

Note: At some point in the near future, NixOS will be packaged officially on the Windows Store. Not useful for this workflow, but something you might be interested in!

MacOS

Open up your Terminal, and run the following command:

$ sh <(curl -L https://nixos.org/nix/install)

Then just follow the steps provided!

Linux
$ sh <(curl -L https://nixos.org/nix/install) --daemon

Then just follow the steps provided!

Now, you should have Nix installed on your system. This will have generated a /nix/store path that will contain everything Nix has installed, and the Nix CLI. You'll likely want to enable flakes if you plan to continue using them after this guide.

Nix the Language

Nix defines everything in a .nix file. This is a declarative configuration, much like Ansible, where you tell the program what to do, rather than how to do it. Here's an example:

{
  environment.variables = {
    MY_VARIABLE = "hello world!";
  };
  environment.systemPackages = [
    nodejs_18
    nodePackages.prettierd
  ];
}

As you can see, through certain existing options, I am able to configure various things, like which packages I'll have available, and what environment variables to set. It's all wrapped in what's called an attribute set, which is basically an object in JavaScript, or a struct in Rust.

Here's a search engine that can help you find all of the available options and packages. These have mixed compatibility with different architectures, so something might not have been implemented for MacOS, or might not be compatible with the model of WSL under Windows. It's always best to check the forums or issues if something weird goes wrong!

The primary difference between Nix and other common languages is that it's a functional language. This can be jarring, and takes some getting used to, but realistically, you won't need to write anything too complex for most configurations, and especially by yourself! These tools are there to build the ecosystem, and develop packages.

The Backbone of a Nix Flake

Flakes are structured slightly differently, instead with inputs and outputs:

{
  inputs = {

  };
  outputs = { each, of, your, inputs, ... }: {

  };
}

inputs are simply other repositories with flakes of their own. The main one you'll be concerned with is the nixpkgs flake, which is used for getting access to packages and options built into Nix, and looks like this:

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs";
  };

And now we can access that from the outputs. outputs can either be an attribute set ({ ... }), or a function that returns an attribute set ({ ... }: { ... }). To access our inputs, we need to use a function, and pull them from our parameters:

  outputs = { nixpkgs, ... }: {

  };

Lastly, we'll want to add an alias for what will eventually be import nixpkgs { system = "x86_64-linux" } (or your architecture). The final result before configuration will look like this:

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs";
  };

  outputs = { nixpkgs, ... }: let
    system = "x86_64-linux";
  in
    {
      devShells."${system}".default = let
        pkgs = import nixpkgs {
          inherit system;
        };
      in pkgs.mkShell {

      };
    };

devShells is an option that Nix exposes to use when using flakes. We're interpolating our system as the property within devShells, and finally setting the default shell to the result of a built-in function: pkgs.mkShell.

If you're using MacOS, you might use x86_64-darwin, and if it's ARM architecture, then aarch64-darwin.

Configuring Our Flake

Now we need some packages, variables, and other things! Let's try developing a Node flake. First we'll need Node itself. Let's go for 22. Searching on search.nixos.org shows us that 22 is available.

...
      in pkgs.mkShell {
        packages = with pkgs; [
          nodejs_22
          yarn
          prettierd
        ];
        shellHook = ''
          export MYPASSKEY="specialpassword";
          # ...
          # other setup steps...
          # ...
          git status
          echo "Ready for development!"
        '';
      };
...

This will, when evaluated, put us in a shell with the packages for node (v22), yarn, and prettierd. These will not be available on any other shell until we run it again, isolating this from the rest of our environment.

Not only that, but shellHook runs everything within the shellHook when it's done evaluating and adding packages. So if you need to update git sources with fetch, or set some variable, this is where to do it!

Building / Evaluating

We've talked a lot about configuration, but let's actually evaluate this! This part is luckily pretty simple. Just run the following command:

nix --extra-experimental-features 'nix-command flakes' develop

Or, if you've enabled flakes already:

nix develop

This will evaluate the flake, and place you in a shell with all the defined packages!

Overriding a Package

Let's say that we really need the exact version of Node 21. Well, in that unlikely scenario, you have options.

...
        packages = with pkgs; [
          (nodejs_22.overrideAttrs (final: prev: rec {
            version = "21.0.0";
            sha256 = "<todo>";
            src = "<todo>";
          }))
        ];
...

Here, we're using a method that is available on most packages, overrideAttrs. This looks pretty scary, but it's actually just a load of boilerplate and a little copy-paste. In fact, all of this is exactly what I'd do to upgrade any package.

You'll notice that there's two todo sections. That's because building can look slightly different for each package. The best way to get src is to go to where the original package is defined: nixpkgs/pkgs/development/web/nodejs/nodejs.nix.

Here, if we look for the property src, we'll find the following:

src = fetchurl {
    url = "https://nodejs.org/dist/v${version}/node-v${version}.tar.xz";
    inherit sha256;
  };
}

The lucky part is that every single package has this attribute, and to upgrade our package, we just need to copy this.

...
        packages = with pkgs; [
          (nodejs_22.overrideAttrs (final: prev: rec {
            version = "21.0.0";
            sha256 = "<todo>";
            src = fetchurl {
              url = "https://nodejs.org/dist/v${version}/node-v${version}.tar.xz";
              inherit sha256;
            };
          }))
        ];
...

sha256 is generated for a package when it's built, so we don't really know what it is yet. For this, Nix gives us the lib.fakeSha256.

...
            sha256 = lib.fakeSha256;
...

Now just rebuild it, wait for the error message, and copy the new sha for got over.

error: hash mismatch in fixed-output derivation '/nix/store/5vrk0g8d7aq65d16935f29s9144z40sy-node-v21.0.0.tar.xz.drv':
specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
got:       sha256-vFYZK5Ua0YNQbcqaz3pNDAJZEUC3/I8lZhN1GZJm8/I=

The truly final result will look like this:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs";
  };

  outputs = { nixpkgs, ... }: let
    system = "x86_64-linux";
  in
    {
      devShells."${system}".default = let
        pkgs = import nixpkgs {
          inherit system;
        };
      in pkgs.mkShell {
        packages = with pkgs; [
          (nodejs_22.overrideAttrs (final: prev: rec {
            version = "21.0.0";
            sha256 = "sha256-vFYZK5Ua0YNQbcqaz3pNDAJZEUC3/I8lZhN1GZJm8/I=";
            src = pkgs.fetchurl {
              url = "https://nodejs.org/dist/v${version}/node-v${version}.tar.xz";
              inherit sha256;
            };
          }))
          yarn
          prettierd
        ];

        shellHook = ''
        export MYPASSKEY="specialpassword"
        git status
        echo "Ready for development!"
        '';
    };
  };
}

Now it should build from source with a simple nix develop!

Conclusion

Nix is complicated. But there is a realistic value proposition that it makes through its complexity: Set things up once. It might be more boilerplate at first, but you can rest assured that each time your run this command, it will work exactly the same way it did every other time.

I hope that you learned something interesting from this article. Nix is a very vast ecosystem, and it's hard to cover so much in one article, but there's plenty of resources out there! Happy coding!