Level Up Your Nix

Learn how to enhance your Nix skills by setting up, configuring, and using Nix Flakes to streamline your development environment and containerization process.

LevelUp Your Nix

Installing Nix

1
sh <(curl -L https://nixos.org/nix/install) --daemon

Enable Flakes

Add the following to ~/.config/nix/nix.conf or /etc/nix/nix.conf:

1
experimental-features = nix-command flakes

Nix Templates

You don’t have to start from zero, you can start with a template.

1
2
3
mkdir project-dir
cd project-dir
nix flake init --template gitlab:usmcamp0811/dotfiles#shell-container

Important Files

  • flake.nix: The main file for your Nix Flake.
  • flake.lock: The lock file that tells Nix what commits/dependencies to use for each of your inputs.
  • .direnv: An optional file that allows you to automatically switch to your Shell when you enter the directory; requires direnv be installed.
  • .gitignore: Every Flake should needs to be a git repository; so we might want to have a .gitignore.

NOTE: Nix can only see files that have been added to the git repo.

Parts of a flake.nix

There are really only two parts to a flake.nix.

inputs

This section contains other Flakes you want to use or have available in your Flake. Each is assigned a local name that can be referenced in the Flake.

All three of the following are the same, just different styles of Nix code.

1
2
3
4
5
inputs = {
  the-flake-name = {
    url = "gitlab:owner/repo/branch";
  };
};
1
inputs.the-flake-name.url = "gitlab:owner/repo/branch";
1
2
3
inputs = {
  the-flake-name.url = "gitlab:owner/repo/branch";
};

Note: You can’t mix styles if you are nesting things. The follwing is not valid.

1
2
3
4
inputs.flakeA.url = "gitlab:owner/repoA/branch";
inputs = {
  flakeB.url = "gitlab:owner/repoB";
};

The correct way would be to do:

1
2
3
4
inputs = {
  flakeA.url = "gitlab:owner/repoA/branch";
  flakeB.url = "gitlab:owner/repoB";
};

or

1
2
inputs.flakeA.url = "gitlab:owner/repoA/branch";
inputs.flakeB.url = "gitlab:owner/repoB";

outputs

This is the section the as the name implies is the output of your Flake. For the purpose of this tutorial session lets break the outputs section into two parts.

letin block

This section is where you can create Nix variables that are the outputs of Nix functions. The functions can do things like create packages or create shells. General programming concepts can apply here, after all it is a programming language.

[]() block

This section is part of the flake.nix that is a little more strucutred. There are some standardized outputs that most all Flakes should have, such as packages or devShells.

Make an Executable Shell Script

A simple example of creating an executable shell script follows. It is a simple script that allows for running the Julia package Pluto.jl from the command line without having to launch the Julia REPL. It gets saved as a Nix variable pluto so we can use it elsewhere in our Flake. Below we also define the julia environment that will have the Pluto and PythonCall packages included. We call that Julia environment julia-env and notice how we used it in the pluto shell script. Finally notice that Nix lazily evaluates all of its code, so the order of defining things generally does not matter.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20

 pluto = pkgs.writeShellScriptBin "pluto" ''
   #!/usr/bin/env bash
   HOST="0.0.0.0" # Default host
   PORT=1234      # Default port

   # Parse command-line arguments for --host and --port
   while [[ "$#" -gt 0 ]]; do
       case $1 in
           --host) HOST="$2"; shift ;;
           --port) PORT="$2"; shift ;;
           *) echo "Unknown parameter passed: $1"; exit 1 ;;
       esac
       shift
   done

   ${julia-env}/bin/julia -e "using Pluto; Pluto.run(host=\"$HOST\", port=$PORT)"
 '';

 julia-env = pkgs.julia.withPackages [ "Pluto" "PythonCall"];

Making a Shell Environment

The following should generally go in the let in block of the outputs section of your flake or in something that gets imported into it. It is a function call that returns a buildEnv that we call shell-env. This isn’t 100% necessary that we do it seperate like we are, but it will allow us to define our environment once, and use it in both a devShell and a OCI compliant container. The function takes a couple of named arguments, the first being name which we just give it a descriptive name. The second argument is paths which is effectively a list of packages to include. For our example we include julia-env which we defined above.

1
2
3
4
5
6
 shell-env = pkgs.buildEnv rec {
   name = "shell-env";
   paths = [
       julia-env
     ];
 };

Below is the actual definition of our shell. There are two arguments (that we have defined here) to pass into the mkShell function, buildInputs and shellHook. The buildInputs is the packages you want to have in your shell environment. Normally you could just place your packages you want inside the list, but because we don’t want to repeat our selves we broke out the above buildEnv and just use that in the buildInput section.

The shellHook can be thought of as your shell’s entrypoint script. It will execute when you enter your shell everytime. For our example we are just echoing some text into the figlet program.

1
2
3
4
5
6
 shell = pkgs.mkShell {
   buildInputs = [ (shell-env) ];
   shellHook = ''
     echo "Example Shell Container with Pluto.jl" | ${pkgs.figlet}/bin/figlet
   '';
 };

Making an OCI Image

You spent all that time getting your environment just right to be able to run your application and now it needs to be containerized so it can run in Kubernetes somewhere. In the traditional Docker workflow you would have to create a Dockerfile and spend a lot of time making sure you included all the dependencies of your application, and their dependencies…and you would have to make sure that the version of the dependency in your base image’s package manager was the correct version (looking at you Debian and Redhat)… several hours later you finally have a Docker image.

With Nix and this way of making your development environment all you have to do to build a container image is the following:

1
2
3
4
5
6
shell-img = pkgs.dockerTools.buildNixShellImage {
  name = "shell-container" ;
  tag = "latest";
  drv = shell;
  command = ''${pluto}/bin/pluto --port ${PORT:-1234}'';
};

This is just a single function call that we assign to the variable shell-img. The function takes four arguments, a name, atag, a drv, and a command which is akin to an entrypoint.

Take note of the drv argument. We are passing shell which is our shell derivation. We also provide a command that is our pluto script we wrote above. So what this will do is create a container image that the only things that exist in it are the things we included in our shell and their dependencies. The entrypoint for the container will be our pluto script.

The Outputs

All the above was in the letin section of the outputs of our Flake, but now we need to actually export those things that we want to make public by defining some common Flake output variables.

1
2
3
4
5
6
7
8
let
# ...
in
{
  devShells.default = shell;
  packages.pluto = pluto;
  packages.container = shell-img;
}

NOTE: You can define as many shells and packages as it makes sense for your flake.

Using the Flake

Flakes don’t necessarily get installed. You can use any flake directly from the remote git repository that it resides or from within the local repository on your computer.

If this flake was in our current directory and it also resided on Gitlab (because GitHub is the devil!) then we could get a shell in our environment we defined either of these two ways.

1
2
3
4
5
# local and in the root of the repo
nix develop .

# remote from gitlab
nix develop gitlab:your-username/this-repo#default

You can build the container image and load it into Docker like this:

1
2
3
4
5
6
7
# local
nix build .#container
docker load -i ./result

# remote
nix build gitlab:your-username/this-repo#container
docker load -i ./result

To run our Pluto program we can do any of the following:

1
2
3
4
5
# running the loaded image
docker run -it --rm -p 8888:1234 shell-container

# or why even go through that trouble just run pluto directly
nix run gitlab:your-username/this-repo#pluto -- --host 127.0.0.1 --port 8888
Built with Hugo
Theme Stack designed by Jimmy