Using Nix as a Professional

Photo by Barn Images / Unsplash

Summary

There's a chance that if you're reading this, you're aware of NixOS or Nix. If not, welcome to a quick summary.

Nix is both a tool and an outright operating system. Although I love it for a Linux operating system, I am going to assume you are using it as a package management tool. In its most pure sense, it allows you to define what version of software and which software you're using declarative language. It's agnostic to operating systems, languages, or tooling. I personally use it with my Mac so that I can have a consistent environment for each of my projects, which, if you've been reading my blog, you'd be aware travels across a lot of languages and tool.

With that quick summary out of the way, let's talk about how to install it, use it, an excellent repository of simple configurations, and a dive into a living example.

Installing Nix

Installing Nix as a tool is extremely easy, thankfully.

They provide instructions for multiple operating systems, but on a Mac you simply run

sh <(curl -L https://nixos.org/nix/install) --darwin-use-unencrypted-nix-store-volume --daemon

Mac has an issue with encrypting volumes within volumes for the Nix store. Although it's perfectly safe, you do have to install it in such a way to deal with encrypted volumes.

This installs Nix, pure and simple. You can test it by running

nix-env --version

If you get a response, you're good to go

Nix Shells

Now that you have the tool, how do you use it?

Maybe not the easiest way, but the correct way is to explicitly declare precisely what you need in a given environment. To do this, you write a file called nix.shell with exactly what you need. Although I have several in personal projects, the master of keeping good nix shells is Seth Doty in his aptly named nix-shells repository. If you grab one of his files and put it in your directory, simply run nix-shell and Nix will grab the packages from the Nix store and build them. Let's grab an example, his Go Nix Shell.

The file itself builds Go 1.14 and provides no additional shell hooks (meaning Go works as a command, but no other aliases)

with import <nixpkgs> { };

stdenv.mkDerivation {
  name = "go";
  buildInputs = [
    go_1_14
  ];
  shellHook = "";
}

The opening is consistent across all shell.nix files where it is importing packages. However, his mkDerivation flag means that it'll grab the go package but tell it specifically to build 1.14 instead of whatever is the latest Go package. Pretty straight forward and simple, yeah? Let's dig into one of his better ones, specifically, the Rust Nix Shell.

let
  moz_overlay = import (builtins.fetchTarball
    "https://github.com/mozilla/nixpkgs-mozilla/archive/master.tar.gz");
  nixpkgs = import <nixpkgs> { overlays = [ moz_overlay ]; };
in with nixpkgs;
stdenv.mkDerivation {
  name = "moz_overlay_shell";
  buildInputs = [
    #nixpkgs.latest.rustChannels.nightly.rust 
    nixpkgs.latest.rustChannels.stable.rust
    rustfmt
    #rustup
    #cargo 
  ];
  shellHook = ''
    alias help="
        echo 'cargo new _'
        echo 'cargo build'
        echo 'cargo check'
        echo 'cargo run'
        echo 'cargo build --release'
    ";
    alias setup="
      rustup install stable
      rustup default stable
    ";
    cd $HOME/workspace/Rust;
  '';
}

This leverages the Firefox build overlay (because Mozilla created Rust, effectively, and started using it in Firefox, which builds in a ton of features and functions.) What's more is that not only does it grab the Rust stable version, but is also sets up aliases based on the rustup toolchain. This means whether you grab this file, I grab this file, or Seth grabs this file, we'll have the same aliases, the same latest Stable version, and the same workspace for Rust. It's consistent across the board and Nix ensures that.

He has plenty more files, including Kubernetes which also plugs you into the demo dashboard, Docker and Docker-Compose and others. They are great foundational files as you build your own. As a professional, it's more than acceptable to start with something working and expand it to what you need.

Once you have the shell.nix of your choice, simply run nix-shell and it'll load your environment up specific to that shell. It'll download, do the build, and load it up just the way you (or Seth) declared it.

So, what do you do next? Commit it into your repo! By having a shell.nix directly in your repo, you and your fellow contributors can git clone a project and immediately run nix shell to be using the exactly same builds for every member with a single command. It's not just a fringe tool, either, professionals in many industries love it.

When do professionals use nix?

In repositories, so that developers are consistent! Some great examples are

  • Waypoint - Hashicorp's tool for automated deployment. (Side note, co-founder of Hashicorp, Mitchell Hashimoto, uses NixOS as his primary development environment and even tweeted about it and keeps his NixOS Configuration public on GitHub.)
  • AT&T - An excellent blog post for explaining Nix versus other solutions.
  • Red Hat - A great video on how and why they are using it.

If we dig into the Waypoint, the first thing you'll notice is a reference to flake

(
  import (fetchTarball https://github.com/edolstra/flake-compat/archive/master.tar.gz) {
    src = builtins.fetchGit ./.;
  }
).shellNix

What Flake does is it takes nix to the next level, by pinning the exact versions you are on to a flake.nix and a flake.lock. How it works is that flake pins to specific repo's /nix/ directory and leverages a combination of *.nix files and most importantly an overlays.nix file. Without getting too deep, overlays allow you to apply explicit definitions for overriding packages with a concept of super arguments. This pins specific versions in Waypoint's overlays.nix by referencing the other *.nix files.

As we can see, the overlays.nix file declares which files to import for version

final: prev: {
  # This is the pinned protoc version we have for this project.
  protobufPin = prev.protobuf3_15;

  devShell = final.callPackage ./waypoint.nix { };

  # Need to manually do this since 1.16 is still the default
  go = final.go_1_17;

  go-protobuf = prev.callPackage ./go-protobuf.nix { };

  go-protobuf-json = prev.callPackage ./go-protobuf-json.nix { };

  go-tools = prev.callPackage ./go-tools.nix { };

  go-mockery = prev.callPackage ./go-mockery.nix { };

  go-changelog = prev.callPackage ./go-changelog.nix { };
}

And if we go check the go-protobuf.nix file, we can see it very specifically defines which to pull down

{ buildGoModule, fetchFromGitHub }:

buildGoModule rec {
  pname = "go-protobuf";
  version = "1.5.2";

  src = fetchFromGitHub {
    owner = "golang";
    repo = "protobuf";
    rev = "v${version}";
    sha256 = "1mh5fyim42dn821nsd3afnmgscrzzhn3h8rag635d2jnr23r1zhk";
  };

  modSha256 = "0lnk2zpl6y9vnq6h3l42ssghq6iqvmixd86g2drpa4z8xxk116wf";
  vendorSha256 = "1qbndn7k0qqwxqk4ynkjrih7f7h56z1jq2yd62clhj95rca67hh9";

  subPackages = [ "protoc-gen-go" ];
}

It also includes the SHA checks to make sure that it is precisely the version used - meaning everyone is using exactly the same version.

Conclusion

Nix is a powerful tool to help your developers to stay consistent in building environments and keeping all your developers on the same page. Although the language is a bit rough to learn, it provides a smooth ride for everyone and reduces the "well it works on my machine" issue that many developers have. Alongside other tools, it can be a great way to reduce downtime due to technical issues and keep working on projects the same across the board.

Marty Henderson

Marty Henderson

Marty is a Staff Cloud Engineer and an AWS Community Builder. Outside of work, he fixes the various 3D printers in his house, drinks copious amounts of iced tea, and tries to learn strange new things.
Madison, WI