How-To Nix (yet another guide to development environments)
You've probably heard of Nix
. If you haven't, well... The landing page explains
everything (just trust me 👀), or maybe it confuses you even more.
Declarative builds and deployments. Nix is a tool that takes a unique approach to package management and system configuration. -- https://nixos.org/
eAsY rIgHt?! The same way the monad is just a monoid in the category of
endofunctors
makes zero sense for me, Nix's description is bit obtuse. Once the
concepts start making sense, it ruins you in a good way. Let's build a tiny
program using Nix, and slowly understand why isolated development environments
are valuable.
Installing Nix
The tooling is available on Linux
, MacOS
, WSL2
, and more. If you want to
immediately go balls deep you can install NixOS
on a useless laptop. The
first time I tried out Nix was on an Ubuntu VM, because I didn't want to screw
up my existing Linux installation.
We'll use Nix Flakes
(more on that later), which is still considered
experimental, but in my opinion essential to ensure a reproducible and
isolated development environment. In order to enable the flakes ecosystem, you
can temporarily enable it when running the nix
command.
nix --experimental-features 'nix-command flakes' help
However, typing it out every time quickly becomes annoying, so you can append it to nix's configuration file.
mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
And if you are using NixOS (nice!). You can add it to your configuration.
nix.settings.experimental-features = [ "nix-command" "flakes" ];
Messing Around with Nix Packages
Let's say you are writing a blog post, and want to showcase a package hello
without polluting your existing Linux installation. I do not have this package
installed, nor do I need it outside the scope of this article. Instead of
installing it, and then forgetting to clean it up, we can ask Nix
to
temporarily make it available.
The nix shell
command starts a new shell and brings packages from the
nixpkgs
flake into the shell environment.
nix shell nixpkgs#hello nixpkgs#cowsay
hello | cowsay
_______________ < Hello, world! > --------------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
Once you exit
the shell, there's no more hello
, and no more cowsay
. It's
as if these packages were never installed. In reality, they are still downloaded
and locally available on your system. If you were to create a shell with the
same packages Nix only has to create a symbolic link to make them available for
use.
# NOTE(Kevin): Notice how fast it executes
nix shell nixpkgs#hello nixpkgs#cowsay --command sh -c "hello | cowsay"
If you want to try out other packages, you can search the Nix Package
Repository
using nix search nixpkgs name-of-package
or via the website.
There's plenty of packages to try out: CLI tools, emulators, editors (yes
neovim), and even more obscure packages might be readily available. According to
Repology the list of available packages looks huge, even compared to the AUR
.
An Actual Practical Guide to Nix Flakes
I don't know about you, but I'm getting tired of constantly typing hello
and
cowsay
whenever I want an elevated shell with these packages. More
importantly, how do we ensure these packages behave exactly the same way on
different machines? There's a chance the channel version of the nixpkgs
is
different than mine, which can yield different results on different machines.
In order to properly pin down the exact version of nixpkgs
we are going to
build a Nix Flake
. Let's initialize our flake, and inspect what it created.
The flake.nix
file looks somewhat familiar to a package.json
, except nix
is an actual programming language for configuring systems.
mkdir -p first-flake
cd first-flake
nix flake init
cat ./flake.nix
The inputs
specify the dependencies of the flake. In our case, we depend on
the nixpkgs
repository under the nixos
organization using the
nixos-unstable
branch. The description
can be whatever you feel like.
If you are unfamiliar with language, the outputs
part can look confusing. In
Javascript, this would be somewhat equivalent to const outputs = ({ self,
nixpkgs }) => ({})
.
{
description = "A very basic flake";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
};
outputs = { self, nixpkgs }: {
packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;
packages.x86_64-linux.default = self.packages.x86_64-linux.hello;
};
}
Let's change the description
, remove the two packages
references, and make
sure the inputs
depend on the more stable nixos-23.11
branch.
The system
variable depends on what processor architecture your system uses.
There's ways to support multiple systems, but that's an exercise for the reader.
The pkgs
variable is how we get access to packages available for our processor
architecture.
Here's a list of possible options (you should change my aarch64-linux
to a value that works for your architecture):
Linux (Intel/AMD):
x86_64-linux
Linux (ARM):
aarch64-linux
MacOS (M1/2/3):
aarch64-darwin
MacOS (Intel/AMD):
x86_64-darwin
{
description = "Hello World from Nix";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
};
outputs = { self, nixpkgs }:
let
system = "aarch64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
# TODO(Kevin): Add isolated dev environment
};
}
Even though we chose a stable version for the nixpkgs
, it's still not clear
how Nix Flakes
pins down the exact version. The nix flake lock
command
generates a lock file, which snapshots the exact git hash the flake uses for the
packages. We have to track the flake.nix
with git
, otherwise the flake
commands won't pick it up.
git add flake.nix
nix flake lock
cat ./flake.lock
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1713145326,
"narHash": "sha256-m7+IWM6mkWOg22EC5kRUFCycXsXLSU7hWmHdmBfmC3s=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "53a2c32bc66f5ae41a28d7a9a49d321172af621e",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-23.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
From this point forward, if two machines use the exact same flake.lock
, the
packages and their dependencies will be exactly the same on both machines. But
what packages though? We haven't referenced any so far. You are right!
Remember the TODO
from earlier?
The devShells.${system}.default
is an attribute that's expected by the flake
when we wish to setup a shell. The pkgs.mkShell
is a function which expects an
attribute set. In this attribute set we can define our packages, by referencing
them via the pkgs
variable.
{
description = "Hello World from Nix";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
};
outputs = { self, nixpkgs }:
let
system = "aarch64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.${system}.default = pkgs.mkShell {
packages = [
pkgs.hello
pkgs.cowsay
];
};
};
}
git add flake.nix
nix flake lock
Now that we've added these packages, everyone who uses nix with flakes, can run
nix develop
to setup the shell, which provides the packages based on the
flake.nix
. Remember that the flake.lock
pins down these packages to the
exact same version, meaning that the packages and their dependencies will be
exactly the same on different machines.
nix develop
hello | cowsay
It's also still possible to invoke the commands using a one-liner.
nix develop --command sh -c "hello | cowsay"
_______________ < Hello, world! > --------------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
Reproducible and Isolated Development Environments for Any Language
If you are familiar with the Node Version Manager
or Python's Virtual
Environments
, you can draw some similarities. Except, you are able to setup the
development environment independently from the languages itself. Are you working
on a project which requires nodejs_18
and python3
? Done. Do you want to
provide a default debugger when working on a C/C++
project? Done. Do you want
setup an environment for Github Actions
? Done!
If a Javascript
project use nix already, anyone who clones the project should
be able to run nix develop
and have a fully working development environment
available. For the sake of the article though, let's assume you want to
contribute to a fictional project which uses bun
. You throw out your computer,
because you didn't know there was yet another way to run Javascript...
Anyways, you add pkgs.bun
to the list of packages inside the pkgs.mkShell
set.
{
description = "Hello World from Nix";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
};
outputs = { self, nixpkgs }:
let
system = "aarch64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.${system}.default = pkgs.mkShell {
packages = [
pkgs.hello
pkgs.cowsay
pkgs.bun
];
};
};
}
git add flake.nix
nix flake lock
Once we start the shell with nix develop
, we are able to run our complicated
app using bun
(as recommended by the fictional project maintainers). Our
application at home:
console.log("Hello from Nix and Javascript!");
If you would like to work inside the development environment provided by nix, we
can again run nix develop
, and run our code. Remember, cowsay
is still
available because we haven't removed it yet from the flake.nix
. The flake pins
down bun
's version, so you are running the exact same version as these
fictional project maintainers.
nix develop
bun ./hello.js | cowsay
Or using the one-liner.
nix develop --command sh -c "bun ./hello.js | cowsay"
________________________________ < Hello from Nix and Javascript! > -------------------------------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
Building and Packaging Applications with Nix
Bundling an application for Node.js
is slightly more involved, and is going to
be an exercise for the reader. We're going to write a small C
program and ship
it with nix
. Don't worry, nothing fancy here.
#include <stdio.h>
int main() {
printf("Hello from Nix and C!");
return 0;
}
git add flake.nix src/hello.c
nix flake lock
Remember the packages we've removed after running nix flake init
? It's time to
bring them back, but tailor them to help us build the C program. The
pkgs.stdenv.mkDerivation
is function with a set as it's argument. The
attributes pname
, version
and src
are required.
In buildPhase
we tell nix how which compiler to use to build our program. The
installPhase
places the executable in a bin folder so nix knows where to find
it when you want to run the application. By default nix run
looks inside the
./result/bin/
folder for an executable with the name taken from pname
.
{
description = "Hello World from Nix";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
};
outputs = { self, nixpkgs }:
let
system = "aarch64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.${system}.default = pkgs.mkShell {
packages = [
pkgs.hello
pkgs.cowsay
pkgs.bun
];
inputsFrom = [
self.packages.${system}.myHello
];
};
packages.${system} = {
myHello = pkgs.stdenv.mkDerivation {
pname = "my-hello";
version = "v0.0.1";
src = ./src;
# NOTE(Kevin): We don't have to go into ./src during the phase, because
# it assumes the working directory to already include the files from src
buildPhase = ''
gcc hello.c -o my-hello
'';
installPhase = ''
mkdir -p $out/bin
cp my-hello $out/bin/
'';
buildInputs = [
];
};
default = self.packages.${system}.myHello;
};
};
}
Running nix build
builds our application, the $out
variable translates to
the ./result
folder. After building it, you can run the executable via
./result/bin/my-hello
.
nix build
nix shell nixpkgs#tree -c tree result
result └── bin └── my-hello 2 directories, 1 file
Or use nix run
, which is the equivalent of running nix build &&
./result/bin/my-hello
without creating the ./result
folder inside the current
working directory.
nix run
Hello from Nix and C!
The perceptive readers might be thinking: "Wait a darn minute! We are missing
pkgs.gcc
, it's not part of the flake? Stop fooling us!
The buildInputs
is the one responsible for defining all the build dependencies
of our package. Yet, we don't need it here because mkDerivation
is a helper
that already bundles gcc
by default. These potential dependencies are also
exposed to the isolated development environment via inputsFrom
. For a
project with actual buildInputs
look here.
The packages from the development environment (nix developer
) are purposely
not available during nix build
and nix run
, in order to build and link the
packages in complete isolation.
Of course running nix develop
in combination with nix run
is not a problem.
nix develop -c sh -c "nix run | cowsay"
_______________________ < Hello from Nix and C! > ----------------------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
Conclusion
There's so much to learn about the Nix
ecosystem, as we've barely touched the
surface. I'm personally still learning a lot about it, even while writing this
article. The main draw for me to use Nix is being able to jump in and out of a
project, and have the whole development environment setup using nix develop
.
If you wish to see a more production level flake.nix
, you can check out my
personal website, uniorg (a project I contribute to) or my dotfiles (nix
configuration for my laptop, desktop, and macbook).
Fun Fact
Nix sounds like "nothing" for a Belgian, so when I'm talking in Dutch about nix,
it often get confusing. I'm trying to understand nothing.