Zero to Nix: Packaging & Development Environments
If you're just landing here, note that this is the second in a series of posts about learning Nix. You can read the other posts if you'd like to learn more about Nix, or continue with this one. I've tried to make them mostly self-contained so you shouldn't read them in order if you're only using them for reference.
All posts:
- Zero to Nix: Everything I Know About Nix & NixOS
- Zero to Nix: Packaging & Development Environments
In the last post we learned about installing Nix and NixOS, accessing REPL, writing some basic Nix expressions and files, and downloading and using packages from <nixpkgs>
package collection. This time, we will learn to write our own packages, learn how we can build ad-hoc development environments that others can use, and how we can build our software using Nix and even learn a little bit about how we can speed up those builds using caching (although caching should probably be discussed in detail later).
Installing Packages
While I do not intend to discuss managing your system's entire configuration in Nix, it is essential that you are able to install basic packages from <nixpkgs>
so you can use them globally on your system. But before we begin, note that depending on the Nix version that you're using, flakes are experimental features. While you can modify the Nix configuration to enable experimental features, for now we're just going to do that by passing in the command line flags when needed. To do that, you can run nix --extra-experimental-features nix-command --extra-experimental-features flakes <subcommand>
. If, however, you've installed Nix using the alternate installer that I mentioned in the previous post, these features are likely already enabled.
The first thing that we need to learn is to find packages that we need. That's easy because one can always look up packages on this Search Engine for Nix packages. Often installing these packages is very easy. To install a package that's available on <nixpkgs>
that you found using the aforementioned search, you can simply enter nix profile install nixpkgs#<package>
. For example, to install jq
, one can run nix profile install nixpkgs#jq
. The jq
package should now be available in your path.
Writing your first Nix package
Let's say that you have a program that you've written in programming language of your choice. Now you'd like to compile this program and share it with others so they can use it with Nix's package management. Here is a small C program that just prints "Hello, world!" that we're going to try our hand on.
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
If you remember from the previous post, a usable Nix package is managed using something called a derivation. We are now going to write a new derivation for our package.
Assuming that you have Nix installed, let's open the directory with your C file and create a default.nix
file there. Now let's write a derivation in our Nix file and we'll go over it.
let
nixpkgs = import (fetchTarball {
url = "https://github.com/NixOS/nixpkgs/archive/nixos-21.11.tar.gz";
sha256 = "04ffwp2gzq0hhz7siskw6qh9ys8ragp7285vi1zh8xjksxn1msc5";
}) { system = builtins.currentSystem; };
in
with nixpkgs;
stdenv.mkDerivation rec {
pname = "hello-world";
version = "0.1.0";
src = ./.;
buildInputs = [ gcc ];
buildPhase = ''
gcc -o hello hello.c
'';
installPhase = ''
mkdir -p $out/bin
cp hello $out/bin/
'';
}
Let's break down the code in our Nix file.
- We declare
nixpkgs
import the contents of the Nix package registry. We use thefetchTarball
function that takes a URL and hash for downloading and extracting a tar file. While importing thenixpkgs
function object, we pass the (optional argument)system
, which is the system for which we are building. We use thebuiltins
module, which has a set of functions that come with Nix to automatically detect the current system and use that. - We call the
stdenv.mkDerivation
function to build a derivation. Note thatstdenv
is available innixpkgs
and since we're using thewith nixpkgs;
syntax, we can call it directly.mkDerivation
function lets us build a derivation (or a Nix package, so to speak). - The
mkDerivation
function takespname
, which is the name of the package, aversion
tag, thesrc
(i.e., source) of any files that we're going to need to build,buildInupts
, which are any other existing packages that we might need to build our own package. Then we usebuildPhase
andinstallPhase
to write shell scripts that will build and install the package. - Building the code is very simple because we just call
gcc
compiler, and for installation we just create an$out/bin
directory and copy over our build artifact to it. Note that in a derivation, if you put anything inside the$out/bin
directory, it is considered an executable output of your package.
Once we have that, we can simply build this code using nix build default.nix
command. Nix will then create a result
directory with a bin
subdirectory containing the output of the binary package.
At this point, I think it's important to note that executable binaries are not the only target of a derivation. One could also build library outputs. If you're writing packages you might want to look at the derivations documentation in Nix reference. Here is a quick list of parameters that you can pass to mkDerivation
function.
name
: A string representing the name of the package, including the version.pname
: A string representing the name of the package, excluding the version. When both pname and version are provided, mkDerivation constructs the name attribute.version
: A string representing the version of the package.src
: A store path or a derivation that produces the source code of the package.buildInputs
: A list of build-time dependencies.nativeBuildInputs
: A list of build-time dependencies that are needed only by the build platform.propagatedBuildInputs
: A list of build-time dependencies that are also propagated to dependent packages.configurePhase, buildPhase, checkPhase, installPhase
: Custom script phases for configuring, building, testing, and installing the package, respectively.preConfigure, postConfigure, preBuild, postBuild, preCheck, postCheck, preInstall, postInstall
: Custom script hooks to be run before or after the corresponding phases.doConfigure, doBuild, doCheck, doInstall
: Booleans that control whether the corresponding phases should be executed. They are set to true by default.installFlags, configureFlags, makeFlags
: Lists of flags to be passed to the corresponding commands during package installation, configuration, or building.meta
: A set of metadata attributes for the package, such as description, homepage, license, platforms, and maintainers.
Flakes & Dependency Management
If you're already somewhat familiar with Nix, then you might have heard of Nix flakes. What we've seen so far are the features of Nix that Nix is fundamentally known for. But over time, people have complained about a lack of better dependency management. This has led to projects such as Niv that help you manage dependencies in a declarative way and be able to pin dependency versions. For example, one you can use command line to add packages to your Nix configuration project like you do with npm
or yarn
with NodeJS or with cargo
in a Rust project and then use a lock file for pinning versions. This trend gave us Nix flakes, which is a feature that allows us to do just that except that it's built into Nix itself. Nix flakes also integrate very well with Git and cloud based Git hosting services like GitHub.
Let's take the example that we have above building a "hello-world" application and convert it to flake.
Alright, let's start. In an empty directory, create the same hello.c
file from previous example. Now, instead of using a default.nix
file, we're going to use a flake.nix
file. You don't have to create this file by hand though; one can bootstrap this using nix flakes init
command (note: use the experimenal features flags if prompted by Nix). Let's now fill the contents of the flake.nix
file with the following before we break down what's happening here:
{
description = "My first flake";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-21.11";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
rec {
defaultPackage = pkgs.stdenv.mkDerivation {
pname = "hello-world";
version = "0.1.0";
src = ./.;
buildInputs = [ pkgs.gcc ];
buildPhase = ''
gcc -o hello hello.c
'';
installPhase = ''
mkdir -p $out/bin
cp hello $out/bin/
'';
};
defaultApp = {
type = "app";
program = "${defaultPackage}/bin/hello";
};
}
);
}
It's entirely a matter of opinion, but I feel that Nix flakes are a lot cleaner than regular Nix expressions. Once you start writing Nix files with more content, having them in flakes will come very handy.
Before I explain further, I'd ask you to try and build and run this flake. You can do this using nix run
command. Nix is automatically able to identify that this is a flake and act accordingly.
But let's take some time to break down the content of our flake.nix
file and what we did with it.
- We declare a description for an object. This is simply a metadata field. You don't really have to declare it, but it's certainly nice to have.
- We declare some inputs. Note that you can also declare inputs using
inputs = { ... }
where you're nesting fields in an object. However, since we don't really have a big object, we opted to use the.
syntax for declaring fields on objects. For our example, we're declaring two inputs -nixpkgs
andflake-utils
. You're already familiar withnixpkgs
from the previous example, andflake-utils
is a nice library to work with flakes that we will discuss in a bit. - Note that we follow
inputs.<input-name>.url
syntax to declare these inputs. Flakes have this brilliant ability to use this syntax and be able to parse different URLs and fetch the dependencies. For example, we're just referencing a GitHub repository from which Nix is supposed to be able to fetch our dependency. This feels a lot more declarative than ourdefault.nix
file where we had to download a Tar archive, validate it using the hash, and then use the contents. When you're finished with this post (and I recommend that you do that first), you can look at the Nix Wiki for flakes and afterwards flake reference documentation to learn more about this syntax. - Once we have the
inputs
sorted, we declare anoutputs
object, which is a function containing the list of inputs that we've specified. Note that this is no different from any other Nix function. Nix flakes is just a utility that knows how to read this Nix file and appropriately parse the inputs to theoutputs
function before executing it. - When you ran
nix run
, you must have noticed that aflake.lock
file was created (if you didn't runnix flake init
already), or simply updated it it already existed. If you look at the lock file, you will see the hashes of dependencies and pinned versions as well as information on how Nix downloaded them. This is where flakes are different from regular Nix. Instead of specifying all these details yourself, you are able to rely on Nix to pin these dependencies such that the dependencies that you've tested your flake with are the same dependencies others use for using your flake. If newer versions of dependencies are available in the future, you can simply update them using thenix flake update
command. Go ahead, try running the update command right away. Likely nothing has changed and Nix will not update anything. - Let's now look at the body of the
outputs
function. This is where things get very interesting. The first thing that we use is to calleachDefaultSystem
function fromflake-utils.lib
module. Remember that we were usingbuiltins.currentSystem
to detect the current system and the build our derivation for that system in our previous example? Because flakes have to "pure" in the sense that everything we do is reproducible and that the generated lock file has all the information regarding the required dependencies and their hashes, we can't directly usebuiltins.currentSystem
. This is because the system can changed, which may change the dependencies that are used and makes the whole process impure. Whatflake-utils
does here is that it provides us with functions and modules for all the possible systems and then using the system it let's us declare outputs for all systems that it knows of. We can, of course, override this but we're not going to discuss that right now. Instead you should read theflake-utils
documentation to learn more about it. - Now that we have a possible system information, we declare
pkgs
object where we usenixpkgs.legacyPackages
for a particular system. We then go ahead and create adefaultPackage
object and assign our derivation to it. By now, you already know how derivations are built so it is probably easy to understand that we're just assigning it to a variable. - Once we have a derivation, we create a
defaultApp
object and assign ittype
andprogram
field. Remember that we were storing our binary in$out/bin
directory. That is exactly where we point ourdefaultApp.program
to. As I've pointed out before, Nix knows that a flake is a specific way of writing our configuration that allows Nix to offer certain extra features like generating aflake.nix
file or directly running the program defined in thedefaultApp
output.
What does our flake contain?
Once you start exploring, you will realise that not all flakes declare defaultApp
output. That's often because they are not runnable apps. The flake-utils
is an example of a flake that outputs functions like lib.eachDefaultSystem
. But does this mean that for each runnable app you have to store your program in the defaultApp
output? What if you have a flake that happens to have multiple programs? Or multiple configuratiosn for a program? For example, what if you want to run a program but just pass in different configuration to it? When we do nix run
, it picks up the program in defaultApp
automatically, but one can also run anything else defined in the flake file. You simply have to refer to it using .#<output-name>
. For example, if your flake exposes a program in an output called my-test-program
, one could run nix run .#my-test-program
. You can also nest your programs. We will later discuss cases where this is very useful.
Another nice thing about flakes is that you can simply run programs in flakes directly by calling their path. In our example above, we called .#my-test-program
because the flake is in the current directory represented by the .
. But let's say that the flake is in a GitHub repository, you can also run it directly using the URL syntax that we used for declaring inputs. As we will later discuss, cargo2nix
is a project that uses flakes. One can run the binary exposed by cargo2nix
by calling nix run github:cargo2nix/cargo2nix
command.
So far we've learned about working with Nix and flakes in Nix. You must have noticed that while flakes are a nice way of writing derivations, they are not the only way of writing Nix configurations. Unfortunately, you will find that the web is full of software fragmented between flakes and regular Nix configurations. In a later post we will discuss combining the use of flakes with the rest of the ecosystem and vice versa.
Another intereting tool to work with flakes is the Nix CLI itself. If you run nix flake show
you will see that it lists an entire tree of everything that the flake contains. If you run the show
subcommand on the flake that you've just written, you will notice that there is a defaultApp.<system-arch>
, which is your application. You'll also see the defaultPackage
derivation that you wrote listed with possible system arch. The different systems are provided by flake-utils
.
Reproducible Development Environments
So we know how to write packages and compile them using Nix. But what if we want to debug these packages? Let's say we have a Python package and we would like to use Python REPL and use it to debug our source code. You can, of course, install Python on your system but then you can't ensure that everyone else in your team is using the same version of Python that you are and that the CI is running tests using the same Python version that you used on your laptop.
We can solve this problem by creating a dev-shell that contains all our dependencies managed by Nix and pinned by flakes. Then anyone else developing on the same project can run tests or work with your project using the exact same dependencies that you used.
Let's modify our flake.nix
file just a tiny bit:
{
# ...
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
let
deps = [ pkgs.gcc ];
in
rec {
defaultPackage = pkgs.stdenv.mkDerivation {
# ...
buildInputs = deps;
# ...
passthru = {
devShell = pkgs.mkShell {
buildInputs = deps;
};
};
};
}
);
}
Notice that we've added a passthru
field in our defaultPackage
object and created a devShell
subfield with buildInputs
. Now try running nix develop
. You will notice that Nix has dropped you into a new shell where the packages defined in the buildInputs
are available. You can now call these packages and since they are the same as the ones used in the derivation. You can run the same code on any other machine with Nix and it will drop you into the same shell.
This is great, but can we do more? What if we wanted multiple shells? What if your project contains contains both front-end and back-end code and you want front-end engineers to be able to load a shell with the tools they need and back-end engineers to load the tools they need, or both? Fortunately, that is very easy to do as well. In fact it's so easy that I'd like you to ignore our previous example entirley and use the method that we will discuss now.
{
# ...
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
rec {
defaultPackage = pkgs.stdenv.mkDerivation {
# <code from our original derivation before we discussed dev shells>
};
defaultApp = {
# <code from our original derivation before we discussed dev shells>
};
devShells = {
default = pkgs.mkShell {
buildInputs = [
pkgs.gcc
];
shellHook = ''
echo "Entering C development environment"
'';
};
python = pkgs.mkShell {
buildInputs = [
pkgs.python3
];
shellHook = ''
echo "Entering Python development environment"
'';
};
};
}
);
}
We now have a top-level devShells
attribute in our outputs
that allows us to define a default
shell and a python
shell in the event that somebody decides to do something with Python instead of gcc
. You can create as many shells as you like. We now even have a shellHook
attribute in our shell that allows you to execute a script when somebody enters a shell. This is particularly useful for printing a message informing user which shell they are in (in case shells are very similar) or for setting environment variables.
Now when you do nix develop
, Nix will automatically drop you into the default
shell. And if you call nix develop .#python
, it will drop you into the python
shell.
Curious readers might notice that while
nix flake run .#package
runs the package defined at the top-level in theoutputs
,nix develop .#python
actually does not run a top levelpython
attribute in theoutputs
but instead runs thedevShells.python
attribute fromoutputs
. At this point, I'd like to remind you that flakes are just a special way to indicate to Nix the format in which the configuration is written and Nix provides us with certain features to use that configuration. The shells in Nix have their own format when working with or without flakes, and it always accesses the content insideoutputs.devShells
object of ourflake.nix
instead of top leveloutputs
.
Automatically Spawning Shell With Direnv
So this is cool, but what if we could automatically load up the entire environment defined in our dev-shell and jump into the shell as soon as we access that directory in our terminal? Fortunately, we have a great tool for that called direnv. Another advantage of using Direnv is that many IDEs and other tools out there also support it so we don't just have access to our reproducible shell in Terminal but also in our IDEs like VSCode or IntelliJ.
We are going to have to install the direnv
package. You can do so using nix profile install nixpkgs#direnv
command. This will install direnv
package and make it available for you to use across your user account on your system.
Once you have direnv
installed, you're going to have to configure your shell to use it. For that, you can just modify your shell's configuration (for example, ~/.zshrc
or ~/.bashrc
) to include the following:
# Replace <your-shell> with the name of your shell. Example: zsh or bash
eval "$(direnv hook <your-shell>)"
Now to setup your project directory to use direnv
, you can create a .envrc
file and simply add the following to it:
use flake
Now when you do direnv allow
, your shell will automatically load the default Nix shell every time you enter the directory. If you'd like to disable it, you can simply run direnv deny
and your shell will not automatically use your dev-shell.
In case you'd like to load a shell that's not your default shell (let's say the Python shell we created), you can modify your .envrc
file to include a DIRNE_FLAKE_PROFILE
variable with the name of the shell that you'd like to use. Here is an example:
export DIRENV_FLAKE_PROFILE="devShell.python"
use flake
That's it! Now you have a reproducible build environment that you can share with others. Note that in order to use reproducible shells, you don't necessairly need to write a derivation for your project. You can completely ignore the defaultApp
or any other attribute in outputs
and only include devShells
attribute.
Flakes in Version Control & A Word Of Caution
Flakes are very good particularly great because they work with Git. Remember that we used github:NixOS/nixpkgs/nixos-21.11
input for nixpkgs
? The nixos-21.11
is the branch on GitHub. If you ignored that part, Nix will just use the default branch from Git. This is a very nice feature that lets you split your code in repositories and import branches and/or tags. You can even include ?dir=<dirctory-name>
at the end of the URL for a repository that includes multiple Nix configurations stored in multiple directories.
BUT one thing that people often forget is that in order for a flake to properly work, the files in the flake have to be added to Git. If you write a flake that does not work, try adding the files to Git. This is an easy to make mistake that has often made me look like a fool so I wanted to point it out before ending this post.
Closing Thoughts
I was very excited to write this post because this is where things get very interesting. But it was also very hard and took much longer than I expected. I hope this was useful and I hope you had the time to go through everything that I discussed here. It goes without saying that there is still a lot more to the topics that we have discussed than I was able to cover, but I think this should be a good enough start. I plan on writing more posts on Nix where we discuss things like managing your system, managing remote hosts, and even running Nix on MacOS. Hope you're as excited about them as I am.