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!