Developing a Jekyll Site on NixOS
This post is long
I won’t fault you if you are trying to solve your own issue and want to skip to the end. (Or if you think I am a terrible writer). So here is a link.
Why Am I Writing This Post?
At some point in time I decided that I wanted to switch from running Linux on a virtual machine to having an installation on my home computer. I shan’t go too deep about the value of Nix (that will be another post), suffice to say I decided to try NixOS for as my Linux distribution of choice.
After setting up my machine and getting everything configured how I like I wanted to make some updates to my personal website. It is a basic statically generated site using Jekyll as well as a few Node libraries and Vips for image transformation. My first attempt was to simply run bundle install
(which worked fine) and run the site. It is not quite that easy on Nix.
NixOS and the ruby-vips
would not work due to expectations it has about library locations. Thus, I had to find a solution and after some research I found nix-shell
(not to be confused with nix shell
).
On the Nix Ecosystem
The Nix ecosystem is still not fully mature so there are competing options in terms of how you might approach having temporary dependencies. Those options beingnix-shell
andnix develop
coupled with Nix flakes. Flakes are currently an “experimental” feature and there was more documentation and content written about the shell option. Thus, I chose to follow thenix-shell
route. I might explore the flake route in the future.
What is nix-shell
?
Lets take a look at what the docs say:
The command
nix-shell
will build the dependencies of the specified derivation, but not the derivation itself. It will then start an interactive shell in which all environment variables defined by the derivation path have been set to their corresponding values, and the script$stdenv/setup
has been sourced. This is useful for reproducing the environment of a derivation for development.
That is great explanation if one actually knows what a derivation is. A derivation is just a fancy name for a build task in Nix. It requires:
- An attribute
system
for examplex86_64-linux
- An attribute
name
fornix-env
to use for packaging - An attribute
builder
which dictates what program will do the building (typically something like${bash}/bin/bash
) - Any other variables will get sent to the build script as environment variables
- For more info you can check the docs or this nix “pill”
So back to the definition from the documents, what exactly does nix-shell
do? It does all the prep work for building the derivation (downloads all dependencies, adds them to PATH
, etc) and stops just before running the genericBuild
function which would build the derivation.
So how does this help us? It means that with a properly set up shell we will have an ephemeral environment that will have all the dependencies need to run out Jekyll website but we won’t need to install anything. In the future, on any system with Nix we will be able to run nix-shell
and we will be able to serve our site.
That sounds compelling. How do we get there?
Starting Point
My initial starting point for making this website work on Nix already included several base like programs including the Ruby, Bundler, Bundix, NodeJs and Npm. The final package that I ended up creating also works without any of those dependencies but building it without them is a pain.
On a completely “pure” build
nix-shell
provides the flag--pure
if you want to try to work on the dependency in a completely pure environment. However, I don’t really recommend it. The process of building itself has many more dependencies than running the site. You are going to quickly run into issues like needing libraries for SSL certificates and other issues.
Thus, what do we actually need to get started?
- Nix - we are trying to make this work on Nix
- Ruby - Jekyll is written in Ruby
- Bundix - a Nix package to simplify getting all the gems we need
Getting Started
Normally, when setting up this project you would bundle install
to get all your gems for local use. Bundix does a pretty similar thing. We can run bundix --magic
to get started.
$ git status
On branch nix-from-scratch
Untracked files:
(use "git add <file>..." to include in what will be committed)
.bundle/
gemset.nix
vendor/
nothing added to commit but untracked files present (use "git add" to track)
We can see that Bundix did several things:
It created a .bundle/config
file. This file simply holds basic configuration options that Bundle will use. Such as BUNDLE_PATH: "vendor/bundle"
, which leads us to the next point.
It created the vendor
directory. But this is just an intermediary place for the gems. Looking at the logs we can follow what is actually happening under the hood.
Fetching webrick 1.81.1
Installing webrick 1.8.1
# ... other installs ommitted
Bundle complete! 12 Gemfile dependencies, 51 gems now installed.
Bundled gems are installed into `./vendor/bundle`
Updating files in vendor/cache
* webrick-1.8.1.gem
# other updates ommited
path is '/nix/store/qjirc89cgjh29d3wi8fz3f23arf2khig-webrick-1.8.1.gem'
13qm7s0gr2pmfcl7dxrmq38asaza4w0i2n9my4yzs499j731wh8r => webrick-1.8.1.gem
# other path fixes ommited
First, Bundler fetches and installs the gem locally. The config options tell it: install the gems to the vendor directory, install all gems locally, even ones already present on the system. Next Bundix stores all the gems in the nix store in paths like /nix/store/somehash…-gem-name-version.gem
. These values are used in the gemset.nix
file. We can now delete the directory vendor/
.
Finally, the gemset.nix
file is the nix derivation that describes all the gems used in the project as well as dependencies, etc.
So How Do We Run It?
Ok, we are close but not quite there. We are gonna need to run 1 more command bundix --init
. Which will create the nix shell file that we will be using:
# shell.nix
with (import <nixpkgs> {});
let
env = bundlerEnv {
name = "personal_site-bundler-env";
inherit ruby;
gemfile = ./Gemfile;
lockfile = ./Gemfile.lock;
gemset = ./gemset.nix;
};
in stdenv.mkDerivation {
name = "personal_site";
buildInputs = [ env ];
}
What is going on here? Honestly, I am not fully sure how it works under the hood. But long story short, Nix will add all them gems from gemset.nix
to the $PATH
and then a default build script will run and build the shell. So lets try and run it. We can use the command nix-shell
for that.
Beginning the Troubleshooting Journey
Platform Issues
Just like that after running nix-shell
we run into our first error.
error: hash mismatch in fixed-output derivation '/nix/store/kfmlg207j62qvxvqlvj1c6xv0biqk2c5-sass-embedded-1.63.6.gem.drv':
specified: sha256-X4goeagmfNKvjFpE+seYwTD3OZyiFgqgi+oagEKnXMo=
got: sha256-TdwW90GmOIwkFA2VpWGqau2BwESPd9B0E808W45Pvvw=
You might think this means that gemset.nix
has the wrong SHA in it, but that is not correct. If we look at the Gemfile.lock
entry for sass-embedded
we might see something strange. sass-embedded (1.63.6-x86_64-linux-gnu)
. Where is that x86
crap coming from?
That is default bundle
behavior that we enabled when adding PLATFORMS x86_64-linux
to our Gemfile
. The behavior by itself is not the issue but it does not play well with bundix.
The Fix
- Option 1: Manually strip the platform ids out of the lock file. Change
1.63.6-x86_64-linux-gnu
to1.63.6
. Repeat for all other cases. - Option 2: Modify the config file. Running the command
bundle config set --local force_ruby_platform true
will addBUNDLE_FORCE_RUBY_PLATFORM: "true"
to the bundle config file.rm gemset.nix; rm Gemfile.lock
to remove the old files and runbundix -l
once again.- Now the
Gemfile.lock
will showruby
as the platform and the gem names will be fixed
Now lets run nix-shell
once again.
sass-embedded
It works right? No shot. This time we get a new error.
fetch https://github.com/sass/dart-sass/releases/download/1.64.2/dart-sass-1.64.2-linux-x64.tar.gz
rake aborted!
SocketError: Failed to open TCP connection to github.com:443 (getaddrinfo: Temporary failure in name resolution)
I don’t fully understand what the issue is, but Nix clearly does not like the gem attempting to download a tarball during its installation.
The Quick Fix
There is actually a pretty easy fix for this. We can simply pin Jekyll to version 4.2.2 which does not rely on sass-embedded
.
- gem "jekyll"
+ gem "jekyll", "~> 4.2.2"
gem "kramdown"
gem "kramdown-parser-gfm"
The Longer Fix
The same post points to a Github issue discussing the problem. Which suggests that you can pass in the location of a tar.gz
file that contains sass-embedded
as SASS_EMBEDDED="path/to/tarball"
. So lets try that. I won’t be getting deep into customizing gem setups because frankly I don’t understand them, but we can modify our shell.nix
to look like this.
# shell.nix
with (import <nixpkgs> { }); let
env = bundlerEnv {
name = "personal_site-bundler-env";
inherit ruby;
gemfile = ./Gemfile;
lockfile = ./Gemfile.lock;
gemset = ./gemset.nix;
gemConfig = {
sass-embedded = attrs: {
SASS_EMBEDDED = pkgs.fetchurl {
url = "https://github.com/sass/dart-sass/releases/download/1.64.2/dart-sass-1.64.2-linux-x64.tar.gz";
sha256 = "";
};
};
};
};
in
stdenv.mkDerivation {
name = "personal_site";
buildInputs = [ env ];
}
This configuration will download the tarball to and provide the downloaded location to the SASS_EMBEDDED
variable. Remember, all configuration values get passed as environment variables to the builder script.
You also might be wondering why sha256 = ""
. At the moment we don’t know what the SHA is. Nix will give us a SHA mismatch error later and we can get the SHA value then.
Lets run nix-shell
again. Aaaaaand we get the same TCP error SocketError: Failed to open TCP connection to github.com:443 (getaddrinfo: Temporary failure in name resolution)
. What gives?
Deeper Investigations
Upon looking into this closer I found that I was not the only one having these issues and people seemed to have come up with differing solutions. Including using the above code, applying a patch to the sass Rakefile
, applying a substitution, downgrading Jekyll, building their own derivation or using dart-sass directly.
How I found all that code
If you are wondering how I found all that code, it was simple. I used Github’s code search. I simply limited by search to only.nix
files with the filterpath:*.nix
and added the necessary words I was looking for:sass-embedded
.
I tried a majority of these methods and could not get many to work. Part of the way that Nix works consistently is it locks in dependencies. So because I was a bit late to the party compared to when some of these people wrote the code for their fixes the source code had changed and I could not use those same fixes.
Exploring Source Code
Thus, I had to look into the actual sass-embedded
Rakefile
to find a solution.
# ext/sass/Rakefile
file 'dart-sass' do |t|
raise if ENV.key?('DART_SASS')
gem_install 'sass-embedded', SassConfig.gem_version, SassConfig.gem_platform do |dir|
cp_r File.absolute_path("ext/sass/#{t.name}", dir), t.name
end
rescue StandardError
archive = fetch(ENV.fetch('DART_SASS') { SassConfig.default_dart_sass })
unarchive archive
rm archive
end
The code in question is above. We can see that what happened is pretty simple. At some point in time there might have been a check for the tarball at SASS_EMBEDDED
but now the check is made at DART_SASS
. Thankfully, it seems like our earlier code should work with just that small change (just swap SASS_EMBEDDED
to DART_SASS
). We run nix-shell
once more and we should get a new error.
error: hash mismatch in fixed-output derivation '/nix/store/vjpjz4f5dxhw11r1j903grkmpfl9jc7f-dart-sass-1.64.2-linux-x64.tar.gz.drv':
specified: sha256-ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0=
got: sha256-+RmtceWz5K2xaJZvuaJs31tocby4H/LwBBV15DRBCzs=
Finally, we can use this mismatched SHA to complete the working code.
It Works!
I have since removed
uncss
I am keeping this part here for historical reasons but I had enough issues in production withuncss
that I ended up removing it. I replaced it withpostcss-purgecss
.
Just kidding. We have a new error.
error: collision between `/nix/store/xrww8y53535zlxlik0bg4p0bgaja45yl-ruby3.1.4-sass-embedded-1.64.2/lib/ruby/gems/3.1.0/bin/sass' and `/nix/store/yj5c4036xb76m27b1vhb8k8nvj62anpm-ruby3.1.4-sass-3.7.4/lib/ruby/gems/3.1.0/bin/sass'
This is another quick fix. I don’t understand the details, but basically you can only have one of the sass
or sass-embedded
as a dependency. sass
and sass-embedded
do the same stuff with sass
being deprecated so we can simply remove sass
from out Gemfile
(don’t forget bundix -l
to update our files).
Or at least we can almost do that. Unfortunately, uncss
relies on sass
. But given they are interchangeable we can simple replace one for the other.
# uncss.rb
# frozen_string_literal: true
- require 'sass'
+ require 'sass-embedded'
require 'tempfile'
require 'json'
nix-shell
once more. Success! We are finally in the Nix Shell.
Nix Shell
Now that we are in the nix shell we can finally run Jekyll and get our site working: bundle exec jekyll serve
.
More Troubleshooting
And just like that something else breaks, this time ruby-ffi
:
bundler: failed to load command: jekyll (/nix/store/jymzy623gl7pacwlr730bp038kqgxbzd-personal_site-bundler-env/lib/ruby/gems/3.1.0/bin/jekyll)
/nix/store/9izdzmylrrpbiyic4ad9n380pbczkcjn-ruby3.1.4-ffi-1.15.5/lib/ruby/gems/3.1.0/gems/ffi-1.15.5/lib/ffi/library.rb:145:in `block in ffi_lib': Could not open library 'glib-2.0.so': glib-2.0.so: cannot open shared object file: No such file or directory. (LoadError)
Could not open library 'libglib-2.0.so': libglib-2.0.so: cannot open shared object file: No such file or directory
What exactly is happening here? ruby-ffi
provides a way to interface with libraries on the system. If we look deeper we can find this code:
# lib/ffi/library.rb
# TODO better library lookup logic
unless libname.start_with?("/") || FFI::Platform.windows?
path = ['/usr/lib/','/usr/local/lib/','/opt/local/lib/', '/opt/homebrew/lib/'].find do |pth|
File.exist?(pth + libname)
end
if path
libname = path + libname
retry
end
end
We can see from the code that the gem is searching for the glib
, gobject
and vips
libraries in the /usr/
and /opt/
directories which will not work with Nix. So what can we do?
The Easy Fix
Nix already has a patched version of ruby-vips
in its packages as rubyPackages.ruby-vips
. However, jekyll_picture_tag
is outdated and requires version 2.0.17 of the gem, which is not available with nixpkgs
.
Note on old packages
Technically the gem is actually available. We can use https://pkgs.on-nix.com/ to find the particular commit in which the gem is still available. Using that SHA and code from https://lazamar.co.uk/nix-versions/ we can use that old version of the gem. Unfortunately, the old version relies on other outdated gems so it simply is not feasible. But you can use old package versions if needed. There might be other ways to do this as well, but I am still a Nix novice.
Thus, the easiest fix is simply to fork the gem and update the .gemspec
to use a modern version of them gem.
# jekyll_picture_tag.gemspec
# ruby-vips interfaces with libvips
- spec.add_runtime_dependency 'ruby-vips', '~> 2.0.17'
+ spec.add_runtime_dependency 'ruby-vips', '~> 2.1.4'
Then you update the Gemfile
with a call to your fork. From the Bundler docs that call will look like:
# Gemfile
gem "jekyll_picture_tag", git: "https://github.com/user_name/repo_name"
The Tougher Fix
The other solution is to essentially do exactly what the Nix package for ruby-vips
does under the hood.
'
is not"
I am not sure if thevips.rb
code has changed but the replace function in the Nix package code matches"vips"
while the code has'vips'
. The single or double quotes matter so make sure you have single quotes in yourshell.nix
.
Lets fix our shell.nix
.
with (import <nixpkgs> { }); let
env = bundlerEnv {
name = "personal_site-bundler-env";
inherit ruby;
gemfile = ./Gemfile;
lockfile = ./Gemfile.lock;
gemset = ./gemset.nix;
gemConfig = {
sass-embedded = attrs: {
DART_SASS = pkgs.fetchurl {
url = "https://github.com/sass/dart-sass/releases/download/1.64.2/dart-sass-1.64.2-linux-x64.tar.gz";
sha256 = "sha256-+RmtceWz5K2xaJZvuaJs31tocby4H/LwBBV15DRBCzs=";
};
};
ruby-vips = attrs: {
postInstall = ''
cd "$(cat $out/nix-support/gem-meta/install-path)"
substituteInPlace lib/vips.rb \
--replace "library_name('vips', 42)" '"${lib.getLib vips}/lib/libvips${stdenv.hostPlatform.extensions.sharedLibrary}"' \
--replace "library_name('glib-2.0', 0)" '"${glib.out}/lib/libglib-2.0${stdenv.hostPlatform.extensions.sharedLibrary}"' \
--replace "library_name('gobject-2.0', 0)" '"${glib.out}/lib/libgobject-2.0${stdenv.hostPlatform.extensions.sharedLibrary}"'
'';
};
};
};
in
stdenv.mkDerivation {
name = "personal_site";
buildInputs = [ env ];
}
Now all the calls to the library_name
function that typically would search for the library are placed with a direct path to the library, so ruby-vips
is happy.
A more detailed explanation of what is going on
As I mentioned earlier in the process of making the shell environment Nix will call the scriptsetup.sh
. This script provides multiple built in functions that we see in the code above.postInstall
is a hook that Nix runs after the install phase. Check this for a reference of all the phases. Hooks in turn are just snippets of code that get called to be executed in a string representation.As you might guess
substituteInPlace
is yet another function provided bysetup.sh
. It does exactly what you think. Other shell functions and utilities are describedin the Nix docs.
Node and Node Packages
At this point we just have Node and Node packages to deal with. If you already have Node and npm
you can just npm install
and run the site. For the sake of this post we are going to cover all that as a shell integration. Lets start by just adding Node.
NodeJS
We just add the node package as a build input to our derivation: buildInputs = [env pkgs.nodejs ];
. buildInputs
is not a function like before, instead it is an environment variable that Nix will loop over with findInputs
to find all the needed build dependencies. It does so in an intelligent way to prevent pulling in dependencies multiple times (you can read more about that process here).
Why dependencies are added in buildInputs
instead of dependencies
? I do not know.
The nodejs
package includes both the V8 node server and npm
so we can just run npm install
within our shell at this point and the website will run.
Packaging Node Modules
If you are a real glutton for punishment you can package your node modules the same way that you packaged your gems. In my experience it is an even more cumbersome process that with Gems so I won’t include that process in this post. But if you want to see how it is done I have made a very short post on how do it here.
Final Code
Here is the final code if you just skipped to the end or you can take a look at the code here.
# shell.nix
with (import <nixpkgs> { }); let
env = bundlerEnv {
name = "personal_site-bundler-env";
inherit ruby;
gemfile = ./Gemfile;
lockfile = ./Gemfile.lock;
gemset = ./gemset.nix;
gemConfig = {
sass-embedded = attrs: {
DART_SASS = pkgs.fetchurl {
url = "https://github.com/sass/dart-sass/releases/download/1.64.2/dart-sass-1.64.2-linux-x64.tar.gz";
sha256 = "sha256-+RmtceWz5K2xaJZvuaJs31tocby4H/LwBBV15DRBCzs=";
};
};
ruby-vips = attrs: {
postInstall = ''
cd "$(cat $out/nix-support/gem-meta/install-path)"
substituteInPlace lib/vips.rb \
--replace "library_name('vips', 42)" '"${lib.getLib vips}/lib/libvips${stdenv.hostPlatform.extensions.sharedLibrary}"' \
--replace "library_name('glib-2.0', 0)" '"${glib.out}/lib/libglib-2.0${stdenv.hostPlatform.extensions.sharedLibrary}"' \
--replace "library_name('gobject-2.0', 0)" '"${glib.out}/lib/libgobject-2.0${stdenv.hostPlatform.extensions.sharedLibrary}"'
'';
};
};
};
in
stdenv.mkDerivation {
name = "personal_site";
# you may want to include pkgs.bundix if you want to mess with the gems more
buildInputs = [ env pkgs.nodejs];
}
Fire it up with nix-shell
then your usual Jekyll commands bundle exec jekyll serve
, etc.
Regarding other dependencies
Having written this code, if you aren’t going to be be making further modifications to the Gemfile, you could remove further dependencies off your system. You could remove Bundix, Bundler and Ruby off your system completely. That said if you want to make changes to the gems you are going to need to add Bundix as abuildInput
which means changing tobuildInputs = [ env pkgs.nodejs-slim pkgs.bundix ];
.Even now I would not recommend trying to run all this as
--pure
because there are still dependencies that are missing from the derivation. It is too time consuming for me to try to find all of them so I am unwilling to try to make it work.
Conclusions
Honestly, this whole process was an absolute pain in the ass. It is pretty crazy to me that I have to jump through so many hoops to basically run bundle install
and have everything work. That said, when the process for installing ruby is as simple as adding system.packages = [ ruby ];
to my configuration I guess it makes sense that there might be issues in other areas. Nix is not beginner friendly but the concept of being able to have full system configuration that is completely portable and repeatable is a huge drawing point for this OS.
I felt like I learned a ton about Nix the OS, the package manager and the Nix language both in making my shell work and in writing this blog post. Even still the functional paradigms of this language make a lot of it pretty hard for me to follow.
A Final Aside
callPackage = path: overrides:
let f = import path;
in f ((builtins.intersectAttrs (builtins.functionArgs f) allPkgs) // overrides);
This is the sort of code that is written in the Nix pills, a series of blog posts aimed to help you learn the Nix system. Without a pretty solid understanding of the language that code is very hard to follow. Given Nix has no penalties for creating more variables I don’t see why it isn’t more common to write more self-documenting code.
callPackage = path: configOverrides:
let
function = import path;
usedPkgConfigs = builtins.intersectAttrs (builtins.functionArgs function) allPkgs;
result = function (usedPkgConfigs // configOverrides);
in
result;
This code isn’t perfect. The function part is still a bit unclear but at least to me is seems a lot more clear what the function actually ends up acting upon.