Building a Jekyll Static Website using Nix

This blog has been down for several months, and I recently at last decided to revive it. The blog is written in Jekyll and building it generally means:

  1. Installing the right version of the Ruby programming language.
  2. Installing the correct version of Ruby libraries.
  3. Building the website using jekyll build.

In order to improve reproducability, I’ll show how to create a Nix derivation for a Jekyll static website. Much of this post also applies to other Ruby projects.

Generate a gemset.nix

Ruby uses Bundler to manage dependencies (called gems). Each Ruby project generally has two files related to Bundler: Gemfile and Gemfile.lock. These are similar to flake.nix and flake.lock in the Nix realm: the Gemfile specifies a project’s high-level dependencies, which is converted to a Gemfile.lock file with a snapshot of exact versions these dependencies revolve to.

Nix, however, doesn’t understand this Gemfile.lock. Therefore we will first have to convert this to a Nix definition. We can do this using Bundix; simply execute nix run nixpkgs#bundix in the directory containing your Gemfile.lock. If you don’t have a Gemfile.lock yet, you can also have Bundix generate it using nix run nixpkgs#bundix --lock This creates a gemset.nix file containing an attrset like:

{
  jekyll = {
    dependencies = ["addressable" "colorator" "em-websocket" "i18n" "jekyll-sass-converter" "jekyll-watch" "kramdown" "kramdown-parser-gfm" "liquid" "mercenary" "pathutil" "rouge" "safe_yaml" "terminal-table"];
    groups = ["default" "jekyll_plugins"];
    platforms = [];
    source = {
      remotes = ["https://rubygems.org"];
      sha256 = "192k1ggw99slpqpxb4xamcvcm2pdahgnmygl746hmkrar0i3xa5r";
      type = "gem";
    };
    version = "4.1.1";
  };
}

As you can see, apart from pinning the version (4.1.1), Bundix also pins the SHA256 hash of the Ruby gem guaranteeing reproducability.

Packaging the Ruby Gems

Now that have all Ruby dependencies neatly version-pinned in Nix, we can have Nix build them for us. Here, bundlerEnv does the heavy lifting for us:

{
    gems = pkgs.bundlerEnv {
        name = "blog-pim";
        gemdir = ./src;
    };
}

The attribute gemdir specifies the directory containing our gemset.nix. Using bundlerEnv like this, gems contains /lib and /bin of all direct and indirect dependencies and puts all executables from these gems on PATH. Also, gems.wrappedRuby contains a Ruby package with access to each of these Ruby gems.

The Nix Derivation

Having access to all Ruby dependencies as Nix packages, we can now build static website using Nix:

{
    static-website = pkgs.stdenv.mkDerivation {
      name = "blog-pim";
      src = ./src;
      sourceRoot = "src";

      buildInputs = [
        gems
        gems.wrappedRuby
      ];

      buildPhase = ''
        bundle exec jekyll build
      '';

      installPhase = ''
        mkdir -p $out
        cp -r _site/* $out/
      '';
    };
}

Set the src attribute to the directory containing your Jekyll source code. Also set sourceRoot to this directory, which jekyll build expects. As inputs to our build process, we have the Ruby gems and the wrapped Ruby program from before. The build phase should be obvious: with our setup we can just build Jekyll as normal. The compiled static website is put in the _site directory, which we copy to our build result in the install phase.

And that’s it for the most part! A fully working flake can be found on my Git website. Let’s build the derivation and check its result:

$ nix build .#packages.x86_64-linux.static-website
$ ll result/
total 180
-r--r--r-- 1 root root  7391 Jan  1  1970 404.html
dr-xr-xr-x 2 root root  4096 Jan  1  1970 about
dr-xr-xr-x 2 root root  4096 Jan  1  1970 ansible-edit-grub
dr-xr-xr-x 2 root root  4096 Jan  1  1970 archive
dr-xr-xr-x 7 root root  4096 Jan  1  1970 assets
dr-xr-xr-x 2 root root  4096 Jan  1  1970 backup-failure
-r--r--r-- 1 root root   262 Jan  1  1970 browserconfig.xml
dr-xr-xr-x 2 root root  4096 Jan  1  1970 concourse-apprise-notifier
...

jekyll-feed’s Monkey Wrench

The above works fine for the most part, but if you use the jekyll-feed Ruby gem, you will unfortunately get a non-deterministic derivation! jekyll-feed creates an Atom feed for your blog posts when compiling the static website. Let’s see why it creates non-deterministic builds:

$ nix build --rebuild --keep-failed .#packages.x86_64-linux.static-website 
note: keeping build directory '/tmp/nix-build-blog-pim.drv-3'
error: derivation '/nix/store/sdphangdgj348ps9x93qwly7kzqalwqf-blog-pim.drv' may not be deterministic: output '/nix/store/193gdfza7d98l573b53sjqswhwfpc30g-blog-pim' differs from '/nix/store/193gdfza7d98l573b53sjqswhwfpc30g-blog-pim.check'

Nix complains our derivation differs between different runs. Let’s see what is different between these two executions:

$ nix run nixpkgs#diffoscope /nix/store/193gdfza7d98l573b53sjqswhwfpc30g-blog-pim /nix/store/193gdfza7d98l573b53sjqswhwfpc30g-blog-pim.check
--- /nix/store/193gdfza7d98l573b53sjqswhwfpc30g-blog-pim
+++ /nix/store/193gdfza7d98l573b53sjqswhwfpc30g-blog-pim.check
│   --- /nix/store/193gdfza7d98l573b53sjqswhwfpc30g-blog-pim/feed.xml
├── +++ /nix/store/193gdfza7d98l573b53sjqswhwfpc30g-blog-pim.check/feed.xml
│ │   --- /nix/store/193gdfza7d98l573b53sjqswhwfpc30g-blog-pim/feed.xml
│ ├── +++ /nix/store/193gdfza7d98l573b53sjqswhwfpc30g-blog-pim.check/feed.xml
│ │ @@ -1,13 +1,13 @@
│ │  <?xml version="1.0" encoding="utf-8"?>
│ │  <feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-US">
│ │    <generator uri="https://jekyllrb.com/" version="4.1.1">Jekyll</generator>
│ │    <link href="https://pim.kun.is/feed.xml" rel="self" type="application/atom+xml"/>
│ │    <link href="https://pim.kun.is/" rel="alternate" type="text/html" hreflang="en-US"/>
│ │ -  <updated>2024-05-03T11:12:54+00:00</updated>
│ │ +  <updated>2024-05-03T11:12:58+00:00</updated>
│ │    <id>https://pim.kun.is/feed.xml</id>
│ │    <title type="html">Pim Kunis</title>
│ │    <subtitle>A pig's gotta fly</subtitle>
│ │    <author>
│ │      <name>Pim Kunis</name>
│ │    </author>
│ │    <entry>

Aha, it seems jekyll-feed uses the current datetime inside the feed to indicate the last change. Patching this was quite easy: I simply replaced this date with the date of the last post. I’ll spare you my terrible Python code, but this can be read here.

I packaged this script like so:

{
  patch-feed-date = pkgs.stdenv.mkDerivation {
    name = "path-feed-date";
    propagatedBuildInputs = [ pkgs.python3 ];
    dontUnpack = true;
    installPhase = "install -Dm755 ${./patch-feed-date.py} $out/bin/patch-feed-date";
  };
}

And I updated the derivation as follows:

{
    static-website = pkgs.stdenv.mkDerivation {
      name = "blog-pim";
      src = ./src;
      sourceRoot = "src";

      buildInputs = [
        gems
        gems.wrappedRuby
        patch-feed-date # Updated
      ];

      buildPhase = ''
        bundle exec jekyll build
      '';

      installPhase = ''
        mkdir -p $out
        cp -r _site/* $out/
        patch-feed-date --file _site/feed.xml > $out/feed.xml # Updated
      '';
    };
}

Let’s check if this fixed our non-deterministic build:

$ nix build --rebuild --keep-failed  .#packages.x86_64-linux.static-website 
$ echo $?
0