When I started writing again, re-building this blog with Jekyll hasn’t been easy. Let’s fix that with Nix.

# Building with Jekyll

Jekyll is powered by Ruby. When I tried building it on macOS, installing its Ruby gem either required sudo or failed when using --user-install1. Installing ruby from homebrew solved this, but:

  • Required to prefix all commands with their full path2, e.g. /opt/homebrew/opt/ruby/bin/bundle exec jekyll serve; and
  • the installation path for gems depended on the current ruby version, e.g. /opt/homebrew/lib/ruby/gems/3.2.0/. The next ruby version upgrade would require re-installing the gems.

I could go on, but… How do we do better?

# Enters Nix

Nix creates reproducible build/execute system. This solves the problems described here – allowing me to forget about this blog and start over in a few years, this time easily! But also provides other goodies:

  • Security: Fully reproducible builds strengthens the supply chain. It pins not only dependencies and their versions along the chain, but also the hashes of their packages (that’s what flake.lock is for).
  • Quality of life: Nix runs everywhere, including Docker and/or the CI. This means we can write checks once (e.g., linters) and run them both locally and remotely. Without turning to yaml to instruct the CI/CD provider or to third party helpers to ease the pain (which would instead increase the supply chain surface).

In theory, this makes Nix great. Where’s the catch? Two things at least. 1. You need to learn it – and it has quite a learning curve. 2. The target build system must collaborate.

# Nix + Bundler

Let’s look at our build system. Jekyll is a Ruby app that relies on Bundler:

Bundler provides a consistent environment for Ruby projects by tracking and installing the exact gems and versions that are needed.

Developers specify their dependencies in a Gemfile. Bundler resolves them and “locks” all their versions in a Gemfile.lock file. This includes any transitive dependency. But, alas for us, Gemfile.lock doesn’t include package hashes (for instance, Python’s Pipfile.lock does this). This makes our build only half-reproducible. What would happen if someone modifies the contents of a Ruby gem that was previously published (and tagged)? We would have no way of noticing on a fresh build!

Solving this requires us to bring in another tool (in addition to nix, and bundler): bundix, which creates one more lock file, gemset.nix, holding the packages hashes that Nix will consume.

Gemfile → Gemfile.lock → gemset.nix Getting dependencies, versions and hashes.

🛑 Let’s stop for a second.

bundix, as all software here, is open-source. Volunteers (often only one) dedicate their free time to build it and maintain it.

It is mind-blowing to consider that these tools even exist. They solve problems well, once, and for all. Think about the effort dedicated to making them so powerful and the impact that we (the people) have when we leverage them.

The issues in this post are first-world problems, that only deal with how I’d like to fit these tools into my workflow.

If you are an opensource contributor: Thank you!
If you are not (yet), please consider supporting your favorite projects by contributing or sponsoring them.

Back to work.

# Bundix: Hammering down issues

To get bundix to work correctly we need to hammer down a few things.

Some Ruby gems provide platform-specific packages. bundix gets confused about them and fetches the wrong package/hash3. We can fix it by asking it to always compile from source (see force_ruby_platform and remember to regenerate your Gemfile.lock).

Also, we now need to keep gemset.nix up-to-date if we make changes to Gemfile.lock.

Don’t be tempted, as I was, to have Nix generate it automatically at build time. That would break reproducibility (again)!

Instead, I tried to ensure this condition by writing a Nix check (technically, a flake check) that would re-generate gemset.nix and fail if different from the original. Alas, this approach didn’t work. Under the hood, bundix calls nix-instantiate, and calling bundix within our check fails – the sandbox prevents us from nesting builds4.

So far, I haven’t found an alternative way to do this. I could write a separate CI check that calls bundix, but that would defeat the point. You win this one, bundix!

# The full nix flake

Writing the rest of the flake.nix file ( full result here) gives us our reproducible system.

We can now run nix run to download any required package/flake, build the blog, and serve it. nix run .lockGemset will (you guessed it) generate gemset.nix. nix flake check, instead, will ensure that the gemset.nix file is in-sync with Gemfile.lock ensure the blog builds.

# 💰 – aka, hidden costs

Hey, did I just read about a bunch of tradeoffs?
That’s right!

To get to reproducible builds, we had to:

  • Bring in additional abstractions (Nix, bundix).
  • Drop pre-compiled libraries and compile everything from source.
  • Introduce the redundancy of gemset.nix.

Every fix and abstraction adds more indirection, which brings it complexity. That we need to know about, manage, evaluate.

OS, nix, bundix, bundler, jekyll, this blog The reproducible (but more complex) stack now powering this blog.

Back to a product mindset how do we weigh benefits and costs? Luckily, we don’t!

I am not at work™, I am doing this for fun. Plus, I get to rant about it.

🥷

Allow me to just point out these hidden costs and not worry about them but just enjoy nix run.
I’ll show myself to the door. ‘til next time! 👋

# Footnotes

  1. Truth to be told, I didn’t try too hard to fix this. 

  2. brew info ruby reads:

    ruby is keg-only, which means it was not symlinked into /opt/homebrew,
    because macOS already provides this software and installing another version in
    parallel can cause all kinds of trouble.
    

  3. This bundix fork deals with native dependencies and their packages. In my case, it failed while starting. The errors hinted to a library version mismatch (maybe due to its Ruby 3.1 vs Ruby 3.2 used for this blog). I decided that was as deep as I would go through the rabbit hole and went back to compiling gems from source. 

  4. This got me quite confused for a second. On macOS, the check I had originally written was working as expected, but it would fail on CI. That’s because sandboxing is disabled on macOS, but enabled on CI.