Jekyll + Nix
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-install
1.
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 nextruby
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.
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 ensure the blog builds.gemset.nix
file is in-sync
with Gemfile.lock
# 💰 – 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.
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
-
Truth to be told, I didn’t try too hard to fix this. ↩
-
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.
-
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. ↩ -
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. ↩