Skip to main content
Nora Codes

Pinning nixpkgs with Morph and Colmena

Leonora Tindall 2024/02/18

I’ve used NixOS and Morph to manage the servers in my house for about a year now, and while I have many gripes with both the Nix language and the documentation around the nixpkgs and NixOS ecosystem, it’s been a great help.

This post is for users of Morph and its near-drop-in replacement, Colmena. If you don’t already use one of these tools, take a look at them first!

Why Pin nixpkgs?

“Pinning”, in this context, refers to making the version of nixpkgs against which my configuration is built an explicit argument, written down and version controlled, rather than an implicit feature of whatever machine the build is run on and its Nix channels.

For a long time, I used Nix channels to manage the versions of packages deployed to my servers. This approach has a lot of drawbacks; for example, it’s difficult to pin a specific version of a package, and it can mean that a simple change requires a full rebuild if the channel has updated. Pinning nixpkgs also means that fixing a broken configuration is just a git revert away, and I can share my config with friends with the confidence that they’ll get the same result as I did.

The breaking poing for me, though, was installing home-manager on my laptop, which is also best managed through a Nix channel - and not the same one.

My servers run NixOS, meaning I need to use the NixOS-versioned home-manager channel for them. On my laptop, though, I gain a lot of useful tools by using the more quick-moving unstable channel. Doing both at the same time is a pain, and that gave me the impetus needed to switch to a pinned nixpkgs.

Pinning with Morph

The easiest way to do this is to simply add a call to builtins.fetchTarball to my Morph network (or Colmena hive, see below). My old network.nix looked like this:

{
  network = {
    description = "Home network";
  };

  "felonyspork" = { ... }: {
    imports = [
      ../../common/default.nix
      ../../hosts/felonyspork/configuration.nix
    ];
    deployment.targetUser = "root";
    deployment.targetHost = "felonyspork";
  };

  # ... other hosts
}

Each host imports common/default.nix, which contained some configuration for nixpkgs:

{ config, pkgs, ... }:
{
  nixpkgs.config = {
    config.allowUnfree = true;
  };

  # ... other configuration
}

fetchTarball requires two arguments: a URL, and the expected hash of the file at that URL, so Nix can tell you if and when it changes. To get the current state of the NixOS nixpkgs channel, first ask Git to get the commit ID for you. In this example, I am using nixpkgs 23.11, but you should use whatever the version is that you’re deploying to your servers.

$ git ls-remote https://github.com/nixos/nixpkgs nixos-23.11
84d981bae8b5e783b3b548de505b22880559515f        refs/heads/nixos-23.11

A simple cut will get just the hash, which can then be inserted into the GitHub archive URL:

$ echo https://github.com/nixos/nixpkgs/archive/$(git ls-remote https://github.com/nixos/nixpkgs nixos-23.11 | cut -f1).tar.gz
https://github.com/nixos/nixpkgs/archive/84d981bae8b5e783b3b548de505b22880559515f.tar.gz

Finally, Nix can prefetch the file, adding it to the Nix store for later use, and emit the hash at the same time.

$ nix-prefetch-url --unpack https://github.com/nixos/nixpkgs/archive/$(git ls-remote https://github.com/nixos/nixpkgs nixos-23.11 | cut -f1).tar.gz
path is '/nix/store/wb41vb32nf7748dr42psfgqw4cjc1pyf-84d981bae8b5e783b3b548de505b22880559515f.tar.gz'
0d6j5d31kzfla0x8f64ranp681dhd0hwxihbf3jjpb18cnddxag8

Equipped with these two pieces of information, you can add a fetchTarball invocation in a binding at the top of your network.nix:

let
  nixos_23_11 = builtins.fetchTarball {
    name = "nixos-23.11-2024-02-18";
    url = "https://github.com/nixos/nixpkgs/archive/84d981bae8b5e783b3b548de505b22880559515f.tar.gz"; 
    sha256 = "0d6j5d31kzfla0x8f64ranp681dhd0hwxihbf3jjpb18cnddxag8";
  };
in {
  network = {
  # ...

This creates a binding (that is, a name for a value) to a path in the Nix store, which can be loaded with import. Loading the Nix files in that folder results in a function which, when called with whatever options you need to set, produces the nixpkgs attrset. That’s then passed to your configuration files as the pkgs argument by Morph.

Note that I’ve used the URL and hash computed here, and used the current date as part of the name. Don’t do that unless you’re reading this on release day, because NixOS will probably have updated since then!

Morph allows you to specify the nixpkgs data in the network metadata section. This is the most natural place to add any configuration options such as allowUnfree. My network.nix ended up looking like the following:

let
  nixos_23_11 = builtins.fetchTarball {
    name = "nixos-23.11-2024-02-18";
    url = "https://github.com/nixos/nixpkgs/archive/84d981bae8b5e783b3b548de505b22880559515f.tar.gz"; 
    sha256 = "0d6j5d31kzfla0x8f64ranp681dhd0hwxihbf3jjpb18cnddxag8";
  };
in {
  network = {
    description = "Home network";
    pkgs = (import nixos_23_11) {
      config.allowUnfree = true;
    };
  };

  "felonyspork" = { ... }: {
    imports = [
      ../../common/default.nix
      ../../hosts/felonyspork/configuration.nix
    ];
    deployment.targetUser = "root";
    deployment.targetHost = "felonyspork";
  }; 

  # ... other hosts
}

Since the nixpkgs options are configured here, they can be removed from other files; in my case, common/default.nix.

Pinning with Git Directly

Nix also provides a way to work with Git repos directly, via builtins.fetchGit. The main advantage over fetchTarball is to be able to specify a branch or other ref, rather than a specific commit SHA. In this case, specifying a commit is the whole idea, so fetchGit is not appropriate.

Pinning with Colmena

Colmena uses a slightly different hierarchy for its network metadata. A hive.nix with the same NixOS 23.11 pin as the above network.nix would look like this:

let
  nixos_23_11 = builtins.fetchTarball {
    name = "nixos-23.11-2024-02-18";
    url = "https://github.com/nixos/nixpkgs/archive/84d981bae8b5e783b3b548de505b22880559515f.tar.gz"; 
    sha256 = "0d6j5d31kzfla0x8f64ranp681dhd0hwxihbf3jjpb18cnddxag8";
  };
in {
  meta = {
    description = "Home network";
    nixpkgs = (import nixos_23_11) {
      config.allowUnfree = true;
    };
  };

  "felonyspork" = { ... }: {
    imports = [
      ../../common/default.nix
      ../../hosts/felonyspork/configuration.nix
    ];
    deployment.targetUser = "root";
    deployment.targetHost = "felonyspork";
  }; 

  # ... other hosts
}

Note that network is renamed to meta, and pkgs is renamed to nixpkgs.

Using a Pinned nixpkgs

One drawback of this approach is having to manually update the pinned version. If this really gets on your nerves, you could use a shell script to do so, as long as your NixOS config is version controlled.

As of writing this post, the policy is for the NixOS binary cache server to hold on to builds forever. This helps with pinned NixOS versions, because it means that even if your pinned version is very out of date, you won’t have to manually rebuild things (except non-free packages, or packages you modify.) This policy, however, is going to change; see this forum thread for more details.