Zero to Nix: Everything I Know About Nix & NixOS

This is first in a series of posts that I intend to write about Nix. If you've just landed here, you can start here or read each post as reference. I've tried to ensure that they are not directly dependent on each other.

All posts:

For those of us who don't know, let me first point out what Nix is, and then we can get started. Nix is a bunch of things — (1) it's a powerful configuration language inspired by Haskell, (2) it's also a package management solution, where package definitions are written in Nix language, and (3) it is an operating system (or rather a Linux distribution), on which (pretty much) every single thing can be configured and setup using Nix language and installed using Nix's package management. Nix also ensures that your configuration is reproducible. It isolates all packages and their dependencies, so you can even have multiple versions of the same package, depending on each package or even a directory. There is a lot more to it, but we'll get to that in a bit.

There are two reasons I'm writing this post:

  • Not very long ago, when I started learning Nix, it wasn't easy to learn everything I needed to know in a way that was easy to understand. A lot of resources answered questions, but didn't tell me exactly how to reason or think in Nix. While things have substantially improved since then, I still feel that there is something I can contribute to the Nix community by writing this post.
  • I am a Nix fanboy who keeps telling others about how great Nix is, and they keep telling me back how hard it is to learn. I want to change that. Next time somebody tells me it's hard, I'm just going to send them the link to this post.

Why Learn Nix?

Most software developers that I know have their personal laptop or personal server/infrastructure configuration stored in a Git repository somewhere. Every time they take up a new job and get a new laptop or buy a new machine, they just run a few commands and install that configuration that they have been maintaining. I had the same. But this tends to get complicated and has limitations when you want to expand on your beautiful configuration and use it everywhere you go.

Nix gives us a powerful language that lets us define our configuration needs, script over them, declarative manage them, and most importantly — it gives us a large ecosystem around it that lets us get productive quickly with existing tools.

Let's skip the small-talk and let's get started now.

Nix Language Crash Course

There are already good tutorials on Nix language, so I'm not going to be super comprehensive, but here is a crash course for you to get started. The guide that follows was sufficient for me to get started, and I hope it is for you as well. If not, you should definitely read the Nix Pills article on the language basics. If I get feedback around it, I will write a more comprehensive guide on Nix language.

Nix has values (or variables), functions, dictionaries (like maps?), lists, and blocks. Let's take a quick look at them.

    let
    	my-variable = "look-a-string";
        my-variable2 = 42;
    in
    let
    	my-function = my-function-arg1: my-function-arg2: {
        	my-object-property-1 = my-function-arg1;
            my-object-property-2 = my-function-arg2;
            my-object-property-list = [ my-variable my-variable2 ];
    	};
    in
    {
    	outputs = my-function "property1" "property2";
    }

See what we did there? We created two variables my-variable and my-variable2 that are respectively string and integer, and then we used that in a function called my-function that returns an object containing my-object-property-<1/2> that is filled by the two arguments as well as a property called my-object-property-list, which is a list of the two variables that we declared.

Let's see what we learned here...

In nix,

  1. We declare variables using = sign, and they have to be in let ... in format where between let and in keyword is our variables and after those keywords is everything that can use those variables.
  2. We can chain let ... in blocks. Each block can use the variables defined by all previous blocks. You can do it as many times as you like!
  3. To define lists, we don't need commas.
  4. We use semicolons when a statement ends.
  5. A list can contain values of different types.
  6. Nix is able to infer types, so we don't have to declare them.
  7. A function uses the format of <function-name> = <argument> : <argument> ... followed by the block of code that can use the function's arguments.
  8. A function is called simply by putting the arguments for that function in front of the function name, separated by space.

Okay, good. Let's go over things that we didn't learn simply by looking at the code above. Don't worry, I'll expand on them later.

  1. Variables are immutable. Therefore, once declared, they cannot be changed (generally speaking).
  2. Functions are curried. Let's expand on this. A function is just a variable of type such that, given multiple arguments, it produces some output. Notice how in our example the function had two arguments separated by : symbol. If you were to call the function with just one argument, let's say newFunction = my-function "some-argument", the newFunction will just be a function with the first argument of the my-function applied. This means that we can build up functions by partially applying their arguments in the order that they appear. This can be very useful for modularity. And if you're not used to this behavior in functions before, I'll just ask you to trust me that this is very useful in the long-term.

Let's take a look at a few more Nix language features before we move on to using Nix. Let's look at two Nix files this time.

    # lib.nix
    { arg1, arg2, ... } @ args: rec {
    	my-variable = [ arg1 args.arg2 ];
        another-variable = args.another-arg;
        recursive-variable = my-variable;
    }
    # default.nix
    {
    	outputs = import ./lib.nix { arg1 = "my-argument"; arg2 = 42; another-arg = 5; };
    }

Let's see what we can learn from that.

  1. Nix expressions can be split into multiple files.
  2. A file can be imported using the import keyword.
  3. We can write file paths directly without any quotes or special characters. Note that this is not only important for importing files, but also for anywhere a file needs to be written. For example, assuming there is a readFile function, the call to the function could be readFile ./my-directory/file.ext. The path buffers are first class citizens in Nix.
  4. In Nix, we write comments using # prefix.
  5. Note that in lib.nix file all we write is a function that takes an object containing fields named arg1 and arg2 and when we import this file, we directly call this function by assigning the arguments to "my-argument" and 42 respectively.
  6. Let's look at the function syntax in lib.nix. Remember that functions are arguments separated by : and the last entry in that sequence is a block where the arguments of the function can be used? It's exactly the same here, but what we do is to expand the arguments and tell Nix that the fields in the input object are named arg1 and arg2. The ... syntax means that there can be more than those two arguments, but we haven't declared their names. Also note that we have @ args syntax. The @ here separates the object with the named fields in our input object and a new variable, in our case args, which contains the object. See how we later use args.another-arg from the args object that was not declared initially? Just remember that we could also call other arguments like args.arg1 even though we mentioned in the object syntax. It doesn't matter if you put the variable input notation or expanded object notation first, as long as they are separated by @.
  7. Inside an object, once declared, the variable cannot be used by other variables. Unless, the object starts with a rec prefix. This means that the fields inside that object can recursively access other fields inside that object. We do that when declaring recursive-variable that just points to the my-variable.

Installing & Using Nix

If you're a complete beginner, the easiest thing to do is not install Nix on your computer, but instead use a NixOS docker container. With Docker, one can easily start a container by calling docker run -it nixos/nix bash. This should be sufficient for learning, since it drops you into a bash shell on a NixOS container.

However, if you want really want to install Nix, there are a few ways to do it.

  1. Install NixOS operating system on your computer. I personally recommend doing this.
  2. You can install only the Nix package management system on your existing operating system using the existing Nix installer scripts. I personally don't like doing this if you're just starting off since it can sometimes break things if you don't know what you're doing.
  3. You can install Nix package management using an unofficial installation scripts. Pay particular attention if you're doing this on macOS (details in the README file), but otherwise this is a lot nicer, and you can uninstall Nix just as easily as you can install it in the event that things don't go as planned.

Trying out the basics

Alright, now that you have Nix installed, let's start with some basic stuff. The first thing that we're going to try is to use the REPL. You can call up Nix REPL using nix repl command.

Once you're there, you can try out the language basics by writing a few expressions. Let's try something like this:

    nix-repl> x = 3
    nix-repl> y = 5
    nix-repl> x + y
    8
    
    nix-repl> import ./default.nix # Let's import the file from the example above
    { outputs = { ... }; }
    nix-repl> file = import ./default.nix
    
    nix-repl> file.outputs
    { another-variable = [ ... ]; my-variable = «repeated»; }
    
    nix-repl> file.outputs.my-variable
    [ "my-argument" 42 ]

That seems to work just fine.

Okay, let's try something more interesting now.

    nix-repl> :l <nixpkgs>
    nix-repl> myFunction = list1: list2: pkgs.lib.concat list1 list2
    nix-repl> myFunction [ 1 2 3 ] [ 4 5 6 ]
    [ 1 2 3 4 5 6 ]

nix repl The :l command tells the REPL to load something. <nixpkgs> is a Nix built-in that references the whole collection of public Nix packages. This collection should then be available under a pkgs object, which further contains a lib object that includes a ton of handy functions to work with while using Nix.

We create a new function call myFunction that takes two arguments list1 and list2. We then use the library function concat to concatenate two lists, and then we call our function with two lists and see them concatenated.

But we could have done this directly using the library function. So how about something more interesting? Let's concatenate two lists and then find out the length of those lists combined!

    nix-repl> myFunction = list1: list2: pkgs.lib.length ( pkgs.lib.concat list1 list2 )
    
    nix-repl> myFunction [ 1 2 3 ] [ 4 5 6 ]
    6

I'd encourage you, dear reader, to play with the REPL before moving onto the next step.

To learn more about the functions available, you can look at NixOS builtins reference.

Nix Shell

We talked about having isolated packages and dependencies. Nix allows us to build development environments such that one can have a shell with a bunch of tools and dependencies required to work on a specific task or project. Then one can use that shell and use all those packages and conveniently use a different shell when working on a different project or task. Let's explore that a bit more. Let's start a new Nix shell with cowsay command line program. This can be done easily using nix shell -p cowsay.

This drops you into a new shell where cowsay package is avilable. You can now call cowsay '<some message>' to get ASCII art of a cow saying whatever you want it to say!

Let's import another package now nix shell -p cowsay fortune will create a new shell with both cowsay and fortune package. You can now pull quotes using fortune and get a cow to recite those in ASCII using fortune | cowsay command.

    [nix-shell:/]# fortune | cowsay
     ________________________________________
    / Often statistics are used as a drunken \
    | man uses lampposts -- for support      |
    \ rather than illumination.              /
     ----------------------------------------
            \   ^__^
             \  (oo)\_______
                (__)\       )\/\
                    ||----w |
                    ||     ||

You can, of course, import as many packages in your shell as you like. But this is starting to get a bit annoying. Development environments can get very complicated. There is an easier way to do this. Let's create a devshell.nix file and populate it with the following content:

    { pkgs ? import <nixpkgs> {} }:
    
    pkgs.mkShell {
      buildInputs = with pkgs; [
        fortune
        cowsay
        vim
      ];
    
      shellHook = ''
        fortune | cowsay
      '';
    }

Let's go through this code now. This file simply declares a function that takes an input object with a field named pkgs. In case the field is not specified, we import <nixpkgs> like we did in the REPL before and assign it to pkgs. Then we call pkgs.mkShell function and provide it with some buildInputs. These inputs are essentially packages that are available in the global package registry. In Nix, these are called derivations. We'll talk more about derivations later.

Do you notice the  with pkgs; syntax there? Every time we start an expression with with <object-name>; the following expression can access the properties of the object directly without writing the object name. For example, there is a pkgs.fortune and pkgs.cowsay and even pkgs.vim derivation. But in order to keep our code clean and short, we write with pkgs; [ cowsay vim fortune ] to avoid writing pkgs. before every derivation name.

Alright, so now that we understand the basics. So what's happening here? We're calling pkgs.mkShell, which is a function that requires fields buildInputs and shellHook. buildInputs is all dependencies for this shell, and shellHook is a command that is executed every time somebody enters this shell. For our shell hook, we simply print a fortune quote using cowsay. But you can essentially execute any hook, including exporting environment variables in this shell.

Nix has a massive ecosystem of packages that are available for use. To find out the packages you might need you can search for them here.

We're only scratching the surface here. No, really, there is a LOT more to Nix and Nix's isolated shells that we haven't discussed. We're going to discuss them next time. Meanwhile, feel free to play with the knowledge that you've accuired today and see how far you can get.

Closing Remarks

It's unfair to assume that one will be able to understand everything about Nix from a single blog post. But this has already been a bit long and I like splitting information in chunks that one can easily go through. So I'll continue sharing what I know about Nix in next posts. Next time we'll learn about how one can write a lot more sophisticated shells, pin dependency versions, configure your computer and even remote computers using Nix and create our own derivations so we can share them with others and even use them across different projects in our personal work and in our organisation.

I hope you learned something new and I was able to get you excited about Nix. If you stick with me, I promise that you will not regret learning about Nix and using it in your everyday life. We'll even discuss using Nix on MacOS son. Till then, happy Nixing!

Next post: Zero to Nix: Packaging & Development Environments