Roberto's blog

Conquer dev environments with Nix

I regularly use different devices with different OSs, such as a MacBook, a Windows desktop with WSL, a couple of Raspberry PIs, and so on. I have a bunch of tools I like to have at my fingertips when I log into a machine, like fd and Neovim. Setting up and maintaining the same configuration across all of my machines using different package managers was painful enough that I just reverted using default tools with no customization in the past. That is until I heard about Nix from Mitchell:

Mitchell’s tweet

These days I use Nix to declare my environment in a single place and reproduce it on different devices and OSs with a single command. I also use Nix (with devenv) to declare the local development environment for my projects without using Docker or virtualization. While language-specific package managers are good at dependencies for their specific language (well, some are…), they cannot provide other dependencies, like the ones you would usually have to install system-wide.

So suppose I want to add the fd command line utility to my environment. I would start by adding an entry for it in my home-manager configuration file:

{ config, pkgs, ... }:

{
  ...

  # Packages that should be installed to the user profile.
  home.packages = [
    pkgs.fd
  ];
}

It’s not important what home-manager is, the point is I have added one line to a strangely-looking configuration file. The configuration file is a Nix expression. Nix is a functional package manager that comes with its domain-specific language – the Nix language. The Nix language is (almost) free of side effects, so there is no networking and file writing and all you can do is massage data. You can think of it as JSON with functions. In the example above, The colon (:) defines a function where at its left are the arguments and at its right is the body which defines an attribute set (think of it as JSON object).

After updating the configuration file, I run home-manager switch to activate the new configuration. The fd command is now available in my environment:

❯ which fd
~/.nix-profile/bin/fd

fd is a symbolic link to a file in the Nix store:

❯ ll ~/~/.nix-profile/bin/fd
lrwxrwxrwx 1 root root 60 Jan  1  1970 /home/roberto/.nix-profile/bin/fd -> /nix/store/y94h7krj29mj1g093sz6054r0bjrmsvl-fd-10.1.0/bin/fd

Everything in Nix is stored under /nix/store (i.e., the Nix store) which represents a graph database where every entry is an immutable node. A node can have references (or edges) to other nodes in the graph. And because Nix store is a graph database, we can query it just like one with the nix-store command line utility. For example, let’s query all immediate dependencies of fd:

❯ nix-store --query --references /nix/store/y94h7krj29mj1g093sz6054r0bjrmsvl-fd-10.1.0/bin/fd
/nix/store/27fg1mkiymj2b344j80kygsbxfcdl5qi-glibc-2.39-52
/nix/store/66qm2gvcb3img7faxli0jv10dahg61qa-gcc-13.2.0-lib
/nix/store/49ims6xcmy6w9jwcn1cfpc6gm66w3x6v-jemalloc-5.3.0

A path in the store is formatted like so /nix/node/{hash}-{name}. The hash is a function of the version of the package and all its dependencies. The hash serves two goals, to prevent clashes between different versions of the same package and to make all the dependencies explicit. This allows two different versions of the same component to live on the same system without interference. Also, the fact that every package needs to live in the store means that there are no implicit dependencies to global locations such as /usr/lib.

So how is a Nix package defined? Enter derivations. A store derivation is a file that ends with .drv in the Nix store that describes how to build other paths in the store. Just about everything in the Nix store is built by derivations, except for derivations.

❯ nix-store --query --deriver /nix/store/y94h7krj29mj1g093sz6054r0bjrmsvl-fd-10.1.0/bin/fd
/nix/store/74cyka9xl6czl59q2crxan59j63npkik-fd-10.1.0.drv

The raw derivation file defines the main output of this derivation (/nix/store/y94h7krj29mj1g093sz6054r0bjrmsvl-fd-10.1.0) and its inputs, such as the source code (/nix/store/wzs9f0fqippllimsvx3b3s282rk0cwpb-source) and build dependencies like rustc-wrapper and cargo:

cat /nix/store/iw0yf27yv4hkxcq1g3ld3rrqkykfp8qy-fd-10.1.0.drv
Derive([("out","/nix/store/y94h7krj29mj1g093sz6054r0bjrmsvl-fd-10.1.0","","")],[("/nix/store/1837g50wdh3qp7q38zcnaprf44ayv6n4-source.drv",["out"]),("/nix/store/2cp6z5wbfcsv7x5rjd3d5qjgiwwnxacd-cargo-check-hook.sh.drv",["out"]),("/nix/store/3sxgvy9qr5glc7qcrp2sf90arll77d41-rustc-wrapper-1.77.2.drv",["out"]),("/nix/store/88cwqzzhqir0rgj5321wiblpg6niq72z-jemalloc-5.3.0.drv",["out"]),("/nix/store/98kv50i6z8i15l1ih9cg5nbis9pyrv6i-install-shell-files.drv",["out"]),("/nix/store/c68ssbqslvmhv3s0vz6v2jbazfd9gwcj-stdenv-linux.drv",["out"]),("/nix/store/d19kf2im34njjvc1kgbd82xlbfd25sri-fd-10.1.0-vendor.tar.gz.drv",["out"]),("/nix/store/kss92h59d059kcvmir8a7p2cj1g0vsbd-cargo-install-hook.sh.drv",["out"]),("/nix/store/p4pbkw89cyf1r5ds8x6cx7wn61ac4nis-bash-5.2p26.drv",["out"]),("/nix/store/wgyx7xklfn540krl0xj6a003lvk1yvnk-auditable-cargo-1.77.2.drv",["out"]),("/nix/store/yc39qdlpl4i9cnyhq6an1668c5fhddfc-cargo-setup-hook.sh.drv",["out"]),("/nix/store/ymj0r0c00vspn5ql39pjhmq62snjrsgq-cargo-build-hook.sh.drv",["out"])],["/nix/store/nk6b2ckznjic5wj8ddw0wgdrn4mbz3lg-patch-registry-deps","/nix/store/v6x3cs394jgqfbi0a42pam708flxaphh-default-builder.sh"],"aarch64-linux","/nix/store/1fzg4cl3k2n9yq80vg6y1vcmvx3qm682-bash-5.2p26/bin/bash",["-e","/nix/store/v6x3cs394jgqfbi0a42pam708flxaphh-default-builder.sh"],[("PKG_CONFIG_ALLOW_CROSS","0"),("__structuredAttrs",""),("buildInputs","/nix/store/49ims6xcmy6w9jwcn1cfpc6gm66w3x6v-jemalloc-5.3.0"),("builder","/nix/store/1fzg4cl3k2n9yq80vg6y1vcmvx3qm682-bash-5.2p26/bin/bash"),("cargoBuildFeatures",""),("cargoBuildNoDefaultFeatures",""),("cargoBuildType","release"),("cargoCheckFeatures",""),("cargoCheckNoDefaultFeatures",""),("cargoCheckType","release"),("cargoDeps","/nix/store/9bbwsk57ihkkkdlrxj0x3yr4hddgsf6h-fd-10.1.0-vendor.tar.gz"),("cargoHash","sha256-3TbsPfAn/GcGASc0RCcyAeUiD4RUtvTATdTYhKdBxvo="),("checkFlags","--skip=test_owner_current_group --skip=test_exec_invalid_utf8 --skip=test_invalid_utf8"),("cmakeFlags",""),("configureFlags",""),("configurePhase","runHook preConfigure\nrunHook postConfigure\n"),("depsBuildBuild",""),("depsBuildBuildPropagated",""),("depsBuildTarget",""),("depsBuildTargetPropagated",""),("depsHostHost",""),("depsHostHostPropagated",""),("depsTargetTarget",""),("depsTargetTargetPropagated",""),("doCheck","1"),("doInstallCheck",""),("mesonFlags",""),("name","fd-10.1.0"),("nativeBuildInputs","/nix/store/x1dxrmmpc4rjfvm4a7gpmd229cqhyswj-install-shell-files /nix/store/i323zs23ig9r6z61qyd4mzb5dsjk24kn-auditable-cargo-1.77.2 /nix/store/78kpm4awskpv385hklrcwyhvgw0fg8d4-cargo-build-hook.sh /nix/store/yzcbb6w8aymwm51xd6zjqx8yazx5vvj8-cargo-check-hook.sh /nix/store/aymndlzzr2ggl505278h59wlwjiiz7ld-cargo-install-hook.sh /nix/store/a37jzda1fnivlqy4p80yfvj7imaf7fgn-cargo-setup-hook.sh /nix/store/k39d1qjy8bz9l3q9jggwwppj090zs502-rustc-wrapper-1.77.2"),("out","/nix/store/y94h7krj29mj1g093sz6054r0bjrmsvl-fd-10.1.0"),("outputs","out"),("patchRegistryDeps","/nix/store/nk6b2ckznjic5wj8ddw0wgdrn4mbz3lg-patch-registry-deps"),("patches",""),("pname","fd"),("postInstall","installManPage doc/fd.1\n\ninstallShellCompletion --cmd fd \\\n  --bash <($out/bin/fd --gen-completions bash) \\\n  --fish <($out/bin/fd --gen-completions fish)\ninstallShellCompletion --zsh contrib/completion/_fd\n"),("postUnpack","eval \"$cargoDepsHook\"\n\nexport RUST_LOG=\n"),("propagatedBuildInputs",""),("propagatedNativeBuildInputs",""),("src","/nix/store/wzs9f0fqippllimsvx3b3s282rk0cwpb-source"),("stdenv","/nix/store/awyrbj9n0di7b7dx1w7w98iyk4i3k9pn-stdenv-linux"),("strictDeps","1"),("system","aarch64-linux"),("version","10.1.0")])%    

The hash of the derivation file is essentially the hash of the content of the derivation. You can think of it as a Merkle hash. Since every build dependency is listed in the derivation file, any dependency change will change the hash of the derivation as well. Hence a hash change for a dependency upstream will propagate through all the nodes that depend on it downstream, causing a cascading rebuild.

The derivation file is generated by a Nix expression that calls the built-in derivation function. Remember when I said Nix is almost free of side effects? Well, the only time Nix has a side effect it’s when it calls the derivation function, which generates a store derivation. So in other words, Nix is a domain-specific language for creating derivations.

The call to the derivation function is defined in Nixpkgs, a package repository for Nix with 100K+ packages. Nixpkgs is just one big Nix expression with 100K lazy evaluated calls to the derivation function. You can think of Nixpkgs as an attribute set defined like this:

{
  fd = derivation { ... };
  nvim = derivation { ... };
  python = derivation { ... };
  ...
}

In order to build fd, various tools force Nix to evaluate the fd attribute of Nixpkgs, which calls the derivation function that generates the .drv file in the Nix store.

The derivation file is built with nix-build, which builds the binary:

❯ nix-build /nix/store/iw0yf27yv4hkxcq1g3ld3rrqkykfp8qy-fd-10.1.0.drv
/nix/store/y94h7krj29mj1g093sz6054r0bjrmsvl-fd-10.1.0

The output path (/nix/store/y94h7krj29mj1g093sz6054r0bjrmsvl-fd-10.1.0) has a different hash than the input derivation because the output’s hash is (simplifying a bit) the hash of the derivation hash and the output name.

When building a derivation, only dependencies declared in the derivation file are available during the build process. The build runs in a sandboxed environment that can’t access libraries in global paths such as /usr/lib. So if a dependency is not explicitly specified, the build will fail. Similarly, the binary will only depend on libraries in the Nix store because they are hardcoded in the binary’s run-time search path (via ELF’s RUNPATH):

❯ ldd /nix/store/y94h7krj29mj1g093sz6054r0bjrmsvl-fd-10.1.0/bin/fd
	linux-vdso.so.1 (0x0000ffff8d804000)
	libjemalloc.so.2 => /nix/store/49ims6xcmy6w9jwcn1cfpc6gm66w3x6v-jemalloc-5.3.0/lib/libjemalloc.so.2 (0x0000ffff8d390000)
	libgcc_s.so.1 => /nix/store/66qm2gvcb3img7faxli0jv10dahg61qa-gcc-13.2.0-lib/lib/libgcc_s.so.1 (0x0000ffff8d350000)
	libc.so.6 => /nix/store/27fg1mkiymj2b344j80kygsbxfcdl5qi-glibc-2.39-52/lib/libc.so.6 (0x0000ffff8d190000)
	/nix/store/27fg1mkiymj2b344j80kygsbxfcdl5qi-glibc-2.39-52/lib/ld-linux-aarch64.so.1 => /lib/ld-linux-aarch64.so.1 (0x0000ffff8d7bc000)
	libm.so.6 => /nix/store/27fg1mkiymj2b344j80kygsbxfcdl5qi-glibc-2.39-52/lib/libm.so.6 (0x0000ffff8d0e0000)
	libstdc++.so.6 => /nix/store/66qm2gvcb3img7faxli0jv10dahg61qa-gcc-13.2.0-lib/lib/libstdc++.so.6 (0x0000ffff8ce80000)
	libpthread.so.0 => /nix/store/27fg1mkiymj2b344j80kygsbxfcdl5qi-glibc-2.39-52/lib/libpthread.so.0 (0x0000ffff8ce50000)

This means there are no implicit dependencies and therefore you could copy a node and its dependencies from one machine to another (with the same architecture) and the binary will run just fine. This opens the door to caching the Nix store so that a software install is just a binary download of a subgraph from a machine with the same platform.

The biggest downside of Nix is its documentation. Not so much the lack of it. If anything there is too much documentation and it’s not written for newcomers in mind (don’t get me started on flakes). And because the documentation sucks, people like me feel compelled to write down their partial understanding of it, creating even more confusion. If you made it this far, check out Burke Libbey’s Nixology series on Youtube – probably the best starting point if you know nothing about Nix.

#Linux