Nix Flakes: Packages and How to Use Them

A 23 minute read.

Cadey is coffee
<Cadey> Nix flakes are still marked as experimental. This documentation has a small chance of bitrotting. I will make every attempt to update it if things change, however flakes have been fairly consistent for a few years now.

Cadey is coffee
<Cadey> EDIT(20220327 14:13): A previous version of this article said to use defaultPackage for the default package. This is deprecated and you should use packages.default instead.

Mara is hmm
<Mara> What is a package? I've seen this term thrown around with phrases like "Nix is a package manager" or "language-specific package manager" or even "download the debian package and install it", but it's not really clear to me what a package is. What is a package?

A package is a bundle of files. These files could be program executables, resources such as stylesheets or images, or even a container image. Most of the time you don't deal with packages directly and instead you use a package manager (a program whose sole goal in life is to deal with packages) to do actions for you. This post is going to cover how to define packages in Nix and how Nix flakes let you manage multiple packages per project more easily.

What is a Package?

In Nix, you build packages by creating derivations that define the build steps and associated inputs (such as the compiler) to end up with the resulting outputs (derivation being the product of deriving something). Consider a package like this:


# hello-shell.nix
with import <nixpkgs> { };
stdenv.mkDerivation {
  name = "hello-HEAD";
  src = ./.;
  installPhase = ''
    echo "Hello" > $out
  '';
}

Then we can build this package with nix-build hello-shell.nix and a result symlink will show up in your current working directory. Then you can view what it says with cat:


$ cat ./result
Hello

This is all it takes to make a Nix package. You need to name the package, give it input source code somehow, and potentially give it build instructions. Everything else we'll cover today will build on top of this.

Let's look back at the Go example package I walked us through in the last post:


# ...
packages = forAllSystems (system:
  let pkgs = nixpkgsFor.${system};
  in {
    go-hello = pkgs.buildGoModule {
      pname = "go-hello";
      inherit version;
      # In 'nix develop', we don't need a copy of the source tree
      # in the Nix store.
      src = ./.;

      # This hash locks the dependencies of this package. It is
      # necessary because of how Go requires network access to resolve
      # VCS.  See https://www.tweag.io/blog/2021-03-04-gomod2nix/ for
      # details. Normally one can build with a fake sha256 and rely on native Go
      # mechanisms to tell you what the hash should be or determine what
      # it should be "out-of-band" with other tooling (eg. gomod2nix).
      # To begin with it is recommended to set this, but one must
      # remeber to bump this hash when your dependencies change.
      vendorSha256 =
        "sha256-pQpattmS9VmO3ZIQUFn66az8GSmB4IvYhTTCFn6SUmo=";
    };
  });
# ...

This uses a different builder, one called pkgs.buildGoModule. This is like the stdenv.mkDerivation builder, except it is explicitly made to handle Go projects. There are some other flags that you can set in buildGoModule that can be useful. You can see examples in the NixOS manual page here.

Another useful builder is Naersk. Naersk will automatically derive build instructions for Rust projects using the Cargo.toml and Cargo.lock files. This means that your build step can look as small as this:


naersk-lib.buildPackage ./.

Mara is hacker
<Mara> You can think of these builders as templates for doing larger builds. This is kinda like the ONBUILD Dockerfile instruction, but it isn't limited to Docker. The main difference is that Nix builds are more like functions (inputs and outputs) and Docker builds focus on the individual commands you run to get the result you want. Both eventually compile down to shell commands anyways!

A More Useful Package

This "hello world" program isn't very useful on its own, however we can use it as the basis for making something a bit more useful. I have made a template for a "Hello world" HTTP server here. Let's make a new folder for it and then initialize it:

Mara is hacker
<Mara> If you want to make your own templates, see how to do that here.


mkdir -p ~/tmp/gohello-http
cd ~/tmp/gohello-http
git init
nix flake init -t github:Xe/templates#go-web-server

Mara is hacker
<Mara> You may see a message from direnv about needing to approve its content. This will use Nix flake's cached interpreter to give you all the advantages of something like Lorri without having to install Lorri.

Then make an initial commit and run it:


git add .
git commit -sm "initial commit"
nix build
./result/bin/web-server

Mara is hmm
<Mara> Why are you using git add . everywhere? Shouldn't the files be picked up implicitly?

Cadey is enby
<Cadey> Not always. Nix flakes only deals with files that are tracked by git when you use it in a git repository. This means that if you want the changes to be observed by Nix, you need to add them to git somehow. git add is good enough for this.

Or you can run it directly with nix run:


nix run

Docker Images

Most of the time you will build software with Nix, however that doesn't stop you from building things like Docker images with Nix. Remember that you can have the output of any shell commands be run in a Nix build (the only catch is that they can't access the internet directly), so you can build a Docker image out of that web server template by defining another package:


# flake.nix

packages = {
  default = ...;
  docker = let
    web = self.packages.${system}.default;
  in pkgs.dockerTools.buildLayeredImage {
    name = web.pname;
    tag = web.version;
    contents = [ web ];

    config = {
      Cmd = [ "/bin/web-server" ];
      WorkingDir = "/";
    };
  };
};

This will build a Docker image with the web-server binary in it. To build it, run these commands:


git add .
nix build .#docker

Mara is hmm
<Mara> What's with that last argument to nix build, won't that be read as a shell comment?

Cadey is enby
<Cadey> It's a reference to the package in the flake. Shell only parses comments when the # is the first character after whitespace, so this is more of a URL fragment than a comment. It's telling nix build to build the flake package named docker.

It will put the resulting docker image in ./result. To load it into docker use the following command:


$ docker load < result
Loaded image: web-server:20220227

Mara is happy
<Mara> Your image tag may differ depending on when you build this image. This is deterministic because that date is derived from the date that the current git commit was made.

Then you can run it with docker run:


docker run -itp 3031:3031 web-server:20220227

Then poke it with curl:


$ curl http://[::]:3031
hello from nix!

You can push this image to the Docker hub like any other image. Another cool thing about this is that when you update the program, it'll only actually load the images that changed. Let's edit the hello world message:


http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintln(w, "hello from nix building a docker image!")
})

And then re-build and load it into Docker:


git add .
nix build .#docker
docker load < result

Mara is hmm
<Mara> Woah, when I did that it only updated 2 layers. The first time that I loaded it there were something like 7 layers. What's up with that?

Cadey is enby
<Cadey> When you use buildLayeredImage, each Nix package that contributes to the image gets put in its own Docker layer. This means that only the things that have changed actually need to be considered, so when you push an updated image to another machine, the only things that will actually be pushed are the application binary and the symlink farm pointing to the contents of the Docker image.

systemd Portable Services

Cadey is coffee
<Cadey> EDIT(20220227 17:24): It seems that these are even more experimental than I thought. systemd Portable Services don't seem to work properly with the StateDirectory and CacheDirectory directives unless you are running a git HEAD version of systemd. Maybe you should wait a few years before trying to use them for anything serious. By then most of the kinks should be worked out.

systemd Portable Services function like Docker, but they work at the systemd level and allow you to integrate into systemd instead of running on the side of it. This gives you access to systemd's readiness signaling, logging pipeline and dependency graph so that you can integrate like a native service. They are like containers, but without a lot of the headaches around networking, stateful storage and logging. They are just systemd services at their core.

Mara is hacker
<Mara> These are kinda like Ubuntu's Snaps or Flatpaks, but they operate purely at the system level and are focused at providing things for system services instead of user-facing applications. Ubuntu's Snaps do let you create system services, but they are basically exclusively used on Ubuntu. systemd Portable Services let you target more than just Ubuntu. In the next few years with more releases of systemd, Portable Services should be easier to use and will be more integrated with the system than Docker is.

There is currently an open pull request for adding Portable Service building support to nixpkgs, however we can mess around with it today thanks to my portable-svc overlay that copies in the contents of that pull request.

Mara is hacker
<Mara> In Nix, an overlay is a set of additional packages or functions that is put on top of nixpkgs. This overlay defines the portableService function that is needed to build portable services.

To make this into a portable service, first we need to add my overlay to the flake inputs:


# flake.nix
inputs = {
  nixpkgs.url = "nixpkgs/nixos-unstable";
  utils.url = "github:numtide/flake-utils";
  portable-svc.url = "git+https://tulpa.dev/cadey/portable-svc.git?ref=main";
};

Then add it as an argument to the outputs function:


outputs = { self, nixpkgs, utils, portable-svc }:

And then change how we are importing the pkgs variable. The pkgs variable we're currently using is imported like this:


let pkgs = nixpkgs.legacyPackages.${system};

This works, however there isn't a way to specify an overlay into this. We need to change this into a manual import of nixpkgs with the overlay specified, like this:


let pkgs = import nixpkgs {
  overlays = [ portable-svc.overlay ];
  inherit system;
};

This will let us use the portableService function in Nix package definitions.

Next we need to make a systemd service unit for the web server. The exact path to the program binary can and will change with every build, so it would be good to have this templated. Make a folder called systemd:


mkdir systemd

And put the following contents in systemd/web-server.service.in:


[Unit]
Description=A web service

[Service]
DynamicUser=yes
[email protected]@/bin/web-server

[Install]
WantedBy=multi-user.target

Then under the docker package definition, add the package that will template out the systemd unit:


web-service = pkgs.substituteAll {
  name = "web-server.service";
  src = ./systemd/web-server.service.in;
  web = self.packages.${system}.default;
};

You can build it with nix build .#web-service, the output will look something like this:


[Unit]
Description=A web service

[Service]
DynamicUser=yes
ExecStart=/nix/store/yl863jm907wfr7gq9j0c4bd3d4bdc4vp-web-server-20220227/bin/web-server

[Install]
WantedBy=multi-user.target

Mara is happy
<Mara> The @[email protected] in the template was replaced with the nix store path for the web server!

Then you can add the bit that builds the portable service:


portable = let
  web = self.packages.${system}.default;
in pkgs.portableService {
  inherit (web) version;
  name = web.pname;
  description = "A web server";
  units = [ self.packages.${system}.web-service ];
};

Then you can build it with nix build:


nix build .#portable

And then take a look at ./result:


$ file $(readlink ./result)
/nix/store/1da6b90i75n03kqlzzfdwxii0j0bzxaf-web-server_20220227.raw: 
Squashfs filesystem,
little endian,
version 4.0,
xz compressed,
9555806 bytes,
2010 inodes,
blocksize: 1048576 bytes,
created: Tue Jan  1 00:00:00 1980

At the time of writing this article, the most reliable way to test portable services is to use Arch Linux. So you could use something like waifud to spin up an Arch Linux VM:


$ waifuctl create -d arch -h logos -s 20
created instance jangmo-o on logos
jangmo-o: running
jangmo-o: init: IP address: 10.77.129.208

Then copy it over with scp:


$ scp (readlink ./result) [email protected]:web-server_20220227.raw

Then you can use portablectl to attach it to the system:


$ sudo portablectl attach ./web-server_20220227.raw
[...]
Created symlink /etc/portables/web-server_20220227.raw → /home/xe/web-server_20220227.raw.

And then start it like any systemd service:


$ sudo systemctl start web-server

Mara is hacker
<Mara> If you want the service to start automatically, add --enable --now to the portablectl attach command. That will enable the service in systemd and then start it, like when you run systemctl enable --now something.service.

And then inspect the service's status with systemctl:


$ sudo systemctl status web-server
● web-server.service - A web service
     Loaded: loaded (/etc/systemd/system.attached/web-server.service; disabled; vendor preset: disabled)
    Drop-In: /etc/systemd/system.attached/web-server.service.d
             └─10-profile.conf, 20-portable.conf
     Active: active (running) since Sun 2022-02-27 18:21:01 UTC; 20s ago
   Main PID: 960 (web-server)
      Tasks: 5 (limit: 513)
     Memory: 8.1M
        CPU: 189ms
     CGroup: /system.slice/web-server.service
             └─960 /nix/store/yl863jm907wfr7gq9j0c4bd3d4bdc4vp-web-server-20220227/bin/web-server

Feb 27 18:21:01 jangmo-o systemd[1]: Started A web service.
Feb 27 18:21:01 jangmo-o web-server[960]: 2022/02/27 18:21:01 listening for HTTP on :3031

And finally poke it with curl:


$ curl http://[::]:3031
hello from nix building a docker image!

Numa is delet
<Numa> That ain't Docker, chief!

Cadey is facepalm
<Cadey> I know, I know, I didn't adjust the message from when I wrote the Docker example.

And then you can change the handler to something like:


http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintf(w, "PORTABLE=%s\n", os.Getenv("PORTABLE"))
})

Rebuild the image with nix build:


$ git add .
$ nix build .#portable

Copy it to the arch VM with scp:


$ scp $(readlink ./result) [email protected]:web-server_20220227.raw

And finally run portablectl reattach to upgrade it:


$ sudo portablectl reattach --now ./web-server_20220227.raw
Queued /org/freedesktop/systemd1/job/858 to call RestartUnit on portable service
web-server.service.

Then you can see that it restarted the unit with systemctl status:


$ sudo systemctl status web-server
● web-server.service - A web service
     Loaded: loaded (/etc/systemd/system.attached/web-server.service; disabled; vendor preset: disabled)
    Drop-In: /etc/systemd/system.attached/web-server.service.d
             └─10-profile.conf, 20-portable.conf
     Active: active (running) since Sun 2022-02-27 18:30:04 UTC; 37s ago
   Main PID: 1074 (web-server)
      Tasks: 6 (limit: 513)
     Memory: 8.1M
        CPU: 182ms
     CGroup: /system.slice/web-server.service
             └─1074 /nix/store/j1mfz3ydn13qmvcgrql33zi0dwb3x7dk-web-server-20220227/bin/web-server

Feb 27 18:30:04 jangmo-o systemd[1]: Started A web service.
Feb 27 18:30:04 jangmo-o web-server[1074]: 2022/02/27 18:30:04 listening for HTTP on :3031

And finally poke it with curl:


$ curl http://[::]:3031
PORTABLE=web-server_20220227.raw

And there you go! Nix created a portable system service, we spawned it on a newly created Arch Linux VM and then were able to update it so that we could replace the message.


Nix builds can do more than just turn code into software. They can create Docker images, Portable Services, virtual machine images and more. The only real limit is what you can imagine.

Flakes make it easier to pull in and munge about packages. Before flakes you'd need to have a few .nix files like docker.nix for the docker image and portable.nix for the portable service. You'd also have to pull in something like Niv to make sure everything uses the same version of nixpkgs, and even then it's opt-in, not opt-out, so it's easy to mess things up and not use the pinned versions of things. Flakes make that explicit behavior implicit, so you can't bring in dependencies you aren't aware of.

If you want to see the code repo I developed while writing this post, see cadey/gohello-http on my git server.

Thanks for reading!


This post was written live on Twitch. You can check out the stream recording on Twitch here and on YouTube here.

This article was posted on M02 27 2022. Facts and circumstances may have changed since publication. Please contact me before jumping to conclusions if something seems wrong or unclear.

Series: nix-flakes

Tags: nix nixos docker systemd

This post was WebMentioned at the following URLs:

The art for Mara was drawn by Selicre.

The art for Cadey was drawn by ArtZora Studios.