What I Wish I knew when I Started Using Nix
This page is for me to share notes of things I was confused by as I’ve used Nix.
Concepts
Nix is different enough from other programming tools that it’s necessary to have some discussion of concepts.
What is Nix
Nix is to managing packages what lisp is to adding numbers together.
More concretely, “Nix” refers to a package manager, the language those packages are written in; and usually referring to “Nix” also includes the main repository of packages (nixpkgs) and the OS (NixOS).
Nix is cool (both in terms of several practical benefits that are difficult to get elsewhere, and in terms of just being elegant), but can be hard to use. – It’s 95% wonderful, 5% pain in the arse.
Perhaps the quickest way to illustrate the benefits: Nix’s UX is like Docker, without dealing with containers.
If you think what you read about Nix (elsewhere) sounds interesting, try it out passively bit by bit over months.
Derivations
“Derivations” confused be for a long time.
If you go through the widely recommended Nix Pills, prob’ly the first surprising Nix Pill is pill 7, its intro to “derivations”.
“Derivation” is a foundational concept in Nix.
As you can see in the blogpost, in one way, it’s like a JSON object.
I met someone who explained that he had been confused for some time about how immutable structures in functional programming could be used in programs which would manipulate the list. (How can something be immutable, but also change?).
With Nix, I felt a similar confusion for a long time. (How can something be a hashmap, but also be a package installed on a filesystem?).
The above pill compares building a .nix
file to building a .c
program.
This is fair, but didn’t click with me.
I’d rather phrase it: that the derivation is the Nix’s “how to build a
package” in its pure, consistent way.
The Nix programs in .nix
files contain programmatic abstractions
built on top of that.
Or a bit more vaguely: a “nix derivation” is more/less like nix’s flavour of a software package.
Other important words: “Instantiate” is “generate the .drv from the
high-level .nix
”, and “realise” is the package actually being built
(or copied) to the store.
The Nix pills take a very bottom-up approach to understanding Nix. I find it hard to appreciate without some familiarity with Nix stuff. But, Nix is much harder to understand without an intuition for what a derivation is.
Realising Derivations
This also confused me for a long time: the relationship between Nix expressions and realised derivations.
What also confused me is a .nix
file might contain many derivations.
Which doesn’t sound all that confusing.
But.
In Python, each Python file is a module, and runs with a main function.
In Haskell, each file is its own module, and runs with a main function.
In Nix, each file is a Nix expression, and files don’t have a specific
format.
A derivation (“nix package”) is declared.
Which itself is maybe not confusing. Terraform code involves declaring
code and letting Terraform determine what resources to
create/delete/update.
And the Nix language is all pure expressions (since it doesn’t have
statements).
– But some combination of the above defied my intuitions.
Packages get built (which you’d expect to be an impure side-effect)
from these declarative Nix expressions in source files, but not
necessarily as simply/explicitly as “package { src = ... }
”.
Flakes
Flakes are a recent addition to Nix.
People who understand flakes say flakes are cool.
(And now that I understand flakes a bit better: yeah, they’re cool).
Flakes are kinda like “nix squared”.
With Nix, you pay an upfront cost; where complexity is managed explicitly.
The implicit, imperative mutation of the global filesystem that
programmers are used to when driving their workstations is verboten.
The typical way of using nixpkgs was using channels. This still leaved some room for inconsistency when installing the same packages across different machines (e.g. due to differences in how up-to-date the channels are in each machine), and leaves some things implicit/mutable. (e.g. what channels are available on the machine).
Nix flakes do away with using channels, and provide a consistent way for a repository to declare its inputs, and what outputs it provides.
Serokell’s “Practical Nix Flakes” blogpost is a good introduction.
With Nix flakes, there’s more complexity upfront, but some pretty neat benefits from paying that cost. (The CLI UX with flakes is nicer; and it’s easier to make use of flakes than non-flakes Nix expressions).
The new Nix commands make use of flakes.
e.g. the old-style command to install a package might be:
nix-env --install --attr nixpkgs.neovim
(install neovim
from the nixpkgs
channel),
but the new-style command is:
nix profile install nixpkgs#neovim
(install neovim
package from the nixpkgs
flake).
e.g. I wrote a flake for my hakyll
blog. I can build & run the static
site generator with just nix run github:rgoulter/my-hakyll-blog
.
How To
nix-env
nix-env
is easiest to start with, but also discouraged for being
footgun-prone.
Using nix-env
can almost be a drop-in replacement for how you might otherwise
use a package manager.
Instead of:
sudo apt-update
sudo apt install vim
you’d run:
nix-channel --update
nix-env --install --attr nixpkgs.vim
(Perhaps unexpectedly: not using sudo
).
There’s only a marginal benefit to Nix compared to the system package manager. (You get the same versions of software on each system, rather than e.g. CentOS’ version being much older than Ubuntu’s; and nixpkgs tend to have very up-to-date packages).
I’ve seen this described as an anti-pattern, and that its use is discouraged. – I’ll say it’s prone to problems of “I forgot I did that” or “I didn’t know I did that” and ending up with problems that are hard to understand.
e.g. when I first installed NixOS, I logged in as the root user and
installed firefox using # nix-env --install --attr nixpkgs.firefox
,
so I could continue reading the manual etc.
Months later, on my normal user account, I was confused why my Firefox
was stuck on such an old version. I was especially confused since
Firefox wasn’t installed to my user profile anywhere.
“I forgot I’d done that. It ended up with a hard to understand problem.”
I think it’s discouraged due to being the cause of a disproportionate amount of requests for help in the community. It’s most likely to be used by beginners, who are likely to not know all the details of what they’re doing, or likely to not be able to figure out why an issue is occuring.
Finding Packages
To search the
nixpkgs
:
nix search nixpkgs <search term>
Declarative Package Management
“Declarative Package Management” is the easiest non-trivial thing to do with Nix.
The Nixpkgs manual has a section describing “declarative package management”.
This at least should be appealing to yak-shavers.
It gives an example using the expression:
{
pkgs.buildEnv name = "my-packages";
paths = [
bc
coreutils
jq
silver-searcher];
};
}
(If you’re persuaded against using nix-env
, nix.dev’s tutorial on
ad hoc developer
environments
is at about the same level).
The first tricky taste of Nix is that there are several different ways to install this package.
Non-exhaustively:
Installing with packageOverrides
At the time of writing, the manual suggests the way to use this
expression is to override the list of packages in nixpkgs and add
myPackages
, by using ~/.config/nixpkgs/config.nix
with contents:
{
packageOverrides = pkgs: with pkgs; {
myPackages = pkgs.buildEnv {
name = "my-packages";
paths = [
bc
coreutils
jq
silver-searcher];
};
};
}
And then installing this by running:
nix-env --install --attr nixpkgs.myPackages
Installing with an Overlay
The chapter immediately following the Declarative Package Management in the nixpkgs manual is about overlays.
Which seems like a nicer solution than the packageOverrides
in config.nix
.
I used this approach for a long time.
I did this by having ~/.config/nixpkgs/overlays/myOverlays.nix
with:
self: super:
{
myPackages = self.buildEnv {
name = "my-packages";
paths = with self; [
bc
coreutils
jq
silver-searcher];
};
}
And then installing this by running:
nix-env --install --attr nixpkgs.myPackages
And just re-running that each time myPackages
is changed.
e.g. here’s the overlay I was using.
Installing with Vanilla flake.nix
For x86-64 Linux, with the following flake.nix
:
{
description = "A basic flake with declaratively managed packages";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
outputs = { self, nixpkgs, flake-utils }: {
packages = let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
x86_64-linux.myPackages = pkgs.buildEnv {
name = "my-packages";
paths = with pkgs; [
bc
coreutils
jq
silver-searcher];
};
};
};
}
(If you put this in a git repository, the file will need to be checked into the repository).
Then, you can install this by running (in the same directory as the
flake.nix
):
nix profile install .#myPackages
A more complex flake.nix
, but with "x86_64-linux"
factored out:
{
description = "A basic flake with declaratively managed packages";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
outputs = { self, nixpkgs, flake-utils }: let
systems = [
"x86_64-linux"
"x86_64-darwin"
];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
in {
packages = forAllSystems (system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
myPackages = pkgs.buildEnv {
name = "my-packages";
paths = with pkgs; [
bc
coreutils
jq
silver-searcher];
};
});
};
}
Installing with Flakes (Nix User Repository)
Probably the tidiest way in the long term may be to organise your Nix code into its own repository.
Making use of nix-community’s nix-user-repository template (i.e. cloning it and pushing to your own fork/repository):
Although maybe not the nicest way of doing it, I’d change default.nix
to have:
{ pkgs ? import <nixpkgs> { } }:
{
myPackages = import ./pkgs/myPackages { pkgs = pkgs; };
}
And I add a file pkgs/myPackages/default.nix
with:
{ pkgs }:
{
pkgs.buildEnv name = "my-packages";
paths = with pkgs; [
bc
coreutils
jq
silver-searcher];
};
}
And then this can be installed with nix profile .#myPackages
, or
nix profile install github:<username>/<repo>#myPackages
or whatever
URL. (The files have to be checked into a git
repository).
e.g. my nix user repository.
Installing with Home Manager
Home Manager is to a user’s dotfiles what NixOS is to a Linux system.
It’s widely used, and widely recommended. And presumably any scheme you come up with will be an ad-hoc, half-baked imitation of what home manager does.
Home Manager can be used to manage what packages you have installed in
your user profile. (Since home manager’s configuration is plaintext and can
list packages to install, you wouldn’t need buildEnv
that the above
examples have).
I’ve used it, but only for managing my dotfiles, and in my experience, I can’t
recommend it to beginners. The NixOS wiki’s page about Home
Manager has an example of using Home
Manager for declarative management of nix-env
.
Seems like it’s worth trying if you’re comfortable copy-pasting now and understanding later, or once you’ve gained more familiarity with Nix.
nix-shell
A nix shell is a shell which leverages Nix to make packages available
when using that shell. (i.e. not installing the program to the user’s
system).
In a way, this is like a cousin to the virtual environments which
various programming environments provide.
It’s also comparable to how Docker images are sometimes used as an
easy way to distribute a toolchain or some CLI program,
or to various “X version managers” like Ruby Version Manager or Node
Version Manager or asdf.
Running the command nix-shell
will make use of a shell.nix
file in
the current directory. A simple shell.nix
file is:
{ pkgs ? import <nixpkgs> {} }:
with pkgs;
{
mkShell packages = [
neovim];
}
Shells with Nix Flakes
“But nix-shell
with a shell.nix
like the above uses nix channels,
not the fancy new flakes”.
The new flake-aware Nix approach to get an ephemeral development
environment in the shell is to integrate with direnv
.
e.g. to use nix-direnv
.
The equivalent of shell.nix
, with flakes? Well…
The nix-direnv project links to the following template flake.nix:
{
description = "A basic flake with a shell";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }:
-utils.lib.eachDefaultSystem (system:
flakelet
pkgs = nixpkgs.legacyPackages.${system};
in {
devShell = pkgs.mkShell {
packages = [ pkgs.neovim ];
};
});
}
which can be generated (along with a .envrc
which makes use of the
flake’s devShell) by running:
nix flake new -t github:nix-community/nix-direnv .
With the .envrc
generated from this template, the flake’s devshell
is automatically built, and its packages made available to the current
shell.
Discussion about Hello World Tutorial
The Nix manual has a tutorial for a simple
expression;
how to write a Nix package for gnu hello
.
This guides us with a default.nix
:
{ stdenv, fetchurl, perl }:
{
stdenv.mkDerivation name = "hello-2.1.1";
builder = ./builder.sh;
src = fetchurl {
url = "ftp://ftp.nluug.nl/pub/gnu/hello/hello-2.1.1.tar.gz";
sha256 = "1md7jsfd8pa45z73bz1kszpp01yw6x5ljkjk2hx7wl800any6465";
};
inherit perl;
}
and a builder.sh
:
source $stdenv/setup
PATH=$perl/bin:$PATH
tar xvfz $src
cd hello-*
./configure --prefix=$out
make
make install
The tutorial apparently suggests to this within the nixpkgs
repository itself, which seems a little impractical, and quite
unintuitive to me.
“How am I supposed to build this?” otherwise brought me to the GitHub
issue NixOS/nix#2259, with
people wondering the same thing.
Because you’ll try running nix-build
and it won’t “just work” with
the above default.nix
.
The easiest “well, this works” default.nix
to stumble across is suggested as:
let
pkgs = import <nixpkgs> {};
in
{
pkgs.stdenv.mkDerivation name = "hello-2.1.1";
builder = ./builder.sh;
src = pkgs.fetchurl {
url = ftp://ftp.nluug.nl/pub/gnu/hello/hello-2.1.1.tar.gz;
sha256 = "1md7jsfd8pa45z73bz1kszpp01yw6x5ljkjk2hx7wl800any6465";
};
perl = pkgs.perl;
}
though now I’d suggest a closer “well, this works” default.nix
file would instead be:
{ pkgs ? import <nixpkgs> {} }:
with pkgs;
{
stdenv.mkDerivation name = "hello-2.1.1";
builder = ./builder.sh;
src = fetchurl {
url = "ftp://ftp.nluug.nl/pub/gnu/hello/hello-2.1.1.tar.gz";
sha256 = "1md7jsfd8pa45z73bz1kszpp01yw6x5ljkjk2hx7wl800any6465";
};
inherit perl;
}
A helpful commenter in the GitHub issue above links to a
StackOverflow
answer
which helps explain the above.
It explains that there’s no single required nor conventional format
for default.nix
. It explains, though, that packages in nixpkgs are
typically in a format for a “callPackage derivation”.
It suggests the rather unwieldy command:
nix-build -E 'with import <nixpkgs> { }; callPackage ./path/to/default.nix { }'
Or perhaps another way of putting it: if we instead renamed the
default.nix
from the tutorial as hello.nix
, a default.nix
file
which could be built with just nix-build
would be:
{ pkgs ? import <nixpkgs> {} }:
./hello.nix {} pkgs.callPackage
or in a more explicit way:
let
pkgs = import <nixpkgs> {};
in
import ./hello.nix {
stdenv = pkgs.stdenv;
fetchurl = pkgs.fetchurl;
perl = pkgs.perl;
}
This … is clearly a bit more involved than you’d expect for “hello world”.
But at this point, the nix pills explain the rationale and the
mechanisms: pill 12 explains the input design
pattern
(in this case, why { stdenv, fetchurl, perl }:
instead of let pkgs = import <nixpkgs> {}; in ...
), and pill 13 the callPackage design
pattern
(which explains the magic of what callPackage
is doing).
But it’s also at this point your knowledge of nix is dangerous enough that you can start to make use of the language-support section of the nixpkgs manual, or the NixOS Wiki.
Opinions
Risks of Nix on non-NixOS
Using Nix on operating systems that aren’t NixOS (macOS, or some other Linux) is the easiest way to get a taste of the Nix package manager.
But it’s also prone to problems you won’t face on NixOS.
e.g. On macOS, I faced confusing errors when mixing Haskell’s stack with nixpkgs. It ended up in such a way that it was trying to use the GHC which stack downloaded, and the C compiler and Haskell packages that Nix had downloaded, and these didn’t mix well for some reason. (The Nix solution was to get haskell stack to use GHC provided by nix, too).
Errors like this end up being quite confusing to work through. Running the compiler from a pure nix shell is one way to mitigate it.
I think most people who use Nix eventually find there way to using NixOS.
NixOS is Easier Than You’d Anticipate
You can expect NixOS is not brutally difficult to use for most things.
In terms of using NixOS on as the OS on your workstation:
Put in the most optimistic pessimistic way: the main difference
between NixOS and a Linux like Arch Linux is that instead of modifying
various files under the /etc/
, the system configuration is all
managed starting from a single configuration.nix
file. – I mean,
using firefox on NixOS is no different than using firefox on Arch
Linux; but changing system configuration files is entirely different.
Rather, I was worried about using NixOS, I was under the impression
that even its happy path was difficult to use.
These days, NixOS is what I drive daily.
I gotta be ambivalent, here:
When stuff goes wrong, in NixOS you may have to understand what’s going on in
order to fix it. (understand what the program you’re running is doing,
understand how its dependencies do what they do, understand how NixOS deals with
those dependencies). NixOS is a layer of tooling to understand above Linux.
There are absolutely people who try NixOS, are able to use it for a bit, and then decide they’re better off with a different OS. I like to think that most people who try NixOS and don’t stick with it stopped using it before they gained a good understanding of Nix.
I think once you grok Nix, then NixOS is wonderful and well worth
trying.
But I’d recommend trying nix on Linux or macOS first to get a taste of
what the advantages and difficulties will be.
Tips
How to Retain nix-shell
so it Doesn’t Get Garbage Collected
https://github.com/NixOS/nix/issues/2208
The sha256
Hash is Used to Identify the Source for fetchFromGitHub, etc.
e.g. in the Nix expression:
{
fetchFromGitHub owner = "istio";
repo = "istio";
rev = "1.8.5";
sha256 = "1mmhiasq2brzhrnklxzxykmannv84wgzpbnqwz40sb8c1n67qzb0";
}
The sha256
value is used by Nix to identify the source. Which means
if you copy paste this, and change the owner
and repo
:
{
fetchFromGitHub owner = "rgoulter";
repo = "my-hakyll-blog";
rev = "v1.0";
sha256 = "1mmhiasq2brzhrnklxzxykmannv84wgzpbnqwz40sb8c1n67qzb0";
}
then nix will see the sha256
is already the same as istio/istio
rev 1.8.5
and use that repository instead.
Instead, the laziest thing to do is just replace the sha256
with an
incorrect value:
{
fetchFromGitHub owner = "rgoulter";
repo = "my-hakyll-blog";
rev = "v1.0";
sha256 = "0000000000000000000000000000000000000000000000000000";
}
And then replace the sha256
with whatever the value is expected to
be. (lib.fakeSha256
can be used instead of however many 0
s that
is).
Another way to find the sha256
value is to use a program like
nix-prefetch.
e.g. running:
nix-prefetch fetchFromGitHub --owner istio --repo istio --rev 1.8.5
will give the correct sha256
value to use.