For a long time, I have been building my Jekyll website on CI using Docker. Plain Ruby image, installing dependencies, caching, running Jekyll build. On local machine I used to manage Ruby development environment. This is a working solution, and generally there is nothing wrong with it. However, right now, I am continuously migrating everything to Nix, and this website is no exception. My environment is mostly managed by Nix. I also had a great experience using it to build a real project, and now it’s time to move to Nix to build this website. In addition to all the benefits of Nix, it’s also good thing to get rid of another “out-of-store” dependency like Docker 👺.

Next I assume that you already have Nix installed.

Alternative configurations

There are some articles about building Jekyll using Nix, such as:

However, all of them are a little bit outdated. Still, there is some interesting information to read, but nowadays there is much more easier way to configure Jekyll + Nix, so read on.

How it will look like

After setting things up, you can use the following commands to interact with your Jekyll website (serve, build and so on):

# defaults to `jekyll build`
nix run .

# `nix run . args` expands to `jekyll args`
nix run . serve

# `nix run .#bundle args` expands to `bundle args`
nix run .#bundle lock

# `nix run .#bundix -- args` expands to `bundix args`
nix run .#bundix -- -m

# Enters development shell with all dependencies installed
nix develop

Sounds nasty: no Dockerfile configuration, no global dependencies, use the single tool to develop locally and build on CI.

So, let’s check the necessary Nix configurations which you need to fill in.

Jekyll + Nix configuration

Everything you need is a single file flake.nix which looks like that:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs";
    bundix = {
      url = "github:inscapist/bundix/main";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    ruby-nix = {
      url = "github:inscapist/ruby-nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = {
    self,
    nixpkgs,
    bundix,
    ruby-nix
  }: let
    system = "x86_64-linux";

    pkgs = import nixpkgs {
      inherit system;
      overlays = [ ruby-nix.overlays.ruby ];
    };
    rubyNix = ruby-nix.lib pkgs;
		bundixcli = bundix.packages.${system}.default;

    deps = with pkgs; [ env ruby bundixcli ];

    inherit (rubyNix {
      name = "seroperson.gitlab.io";
      gemset = ./gemset.nix;
      gemConfig = pkgs.defaultGemConfig;
    })
      env ruby;
  in {
    packages.${system} = let
      bundlecli = pkgs.writeShellApplication {
        name = "bundle";
        runtimeInputs = deps;
        text = ''
          export BUNDLE_PATH=vendor/bundle
          bundle "$@"
        '';
      };
      jekyll = pkgs.writeShellApplication {
        name = "jekyll";
        runtimeInputs = deps;
        text = ''
          if [ $# -eq 0 ]; then
            jekyll build
          else
            jekyll "$@"
          fi
        '';
      };
    in {
      jekyll = jekyll;
      bundle = bundlecli;
      bundix = bundixcli;
      default = jekyll;
    };

    devShells.${system}.default = pkgs.mkShell {
      shellHook = ''
        export BUNDLE_PATH=vendor/bundle
      '';
      buildInputs = deps;
    };
  };
}

In short, it’s just the default ruby-nix configuration and some Nix aliases. Nothing more, actually. In fact, the minimal Nix configuration for Jekyll is even shorter, but those aliases are so useful that I decided to include them here too.

When flake.nix is created, you will need to generate a gemset.nix file by running the following command:

nix run .#bundix -- -m

bundix is a tool which pins dependencies for Nix according to Gemfile.lock. Be sure to put gemset.nix under a git control and further remember to keep your Gemfile.lock and gemset.nix files synchronized.

Finally, don’t forget to edit your Jekyll _config.yml to not publish your nix files:

# ...
exclude:
  - gemset.nix
  - flake.nix
  - flake.lock
# ...

That’s it. Now everything should work.

Additional configuration for plugins with native libraries

If you are using the jekyll_picture_tag (with libvips) or any other native library, you may also need to add some additional configuration to make everything work. Many libraries are already covered by Nix (with a bunch of hacks), and mostly they just work, but sometimes strange things happen. If errors like Could not open library occur, this is probably your case. The solution differs for each case, but the general approach is the same: in the package sources, you should replace the default native library references with nixified references. That’s how it looks like for ruby-vips:

{
  inputs = {
    # ...
  };

  outputs = {
    # ...
  }: let
    # ...
    inherit (rubyNix {
      # ...
      gemConfig = pkgs.defaultGemConfig // {
        ruby-vips = attrs: {
          postInstall = ''
            cd "$(cat $out/nix-support/gem-meta/install-path)"

            substituteInPlace lib/vips.rb \
              --replace "library_name('vips', 42)" '"${pkgs.vips.out}/lib/libvips${pkgs.stdenv.hostPlatform.extensions.sharedLibrary}"' \
              --replace "library_name('glib-2.0', 0)" '"${pkgs.glib.out}/lib/libglib-2.0${pkgs.stdenv.hostPlatform.extensions.sharedLibrary}"' \
              --replace "library_name('gobject-2.0', 0)" '"${pkgs.glib.out}/lib/libgobject-2.0${pkgs.stdenv.hostPlatform.extensions.sharedLibrary}"'
          '';
        };
      };
    })
      env ruby;
  in {
    # ...
  };
}

Configuring CI with nixified Jekyll

Well, now we are building our Jekyll website using Nix. We are building it locally, so why not build it in the same way on CI? This allows us to use the same environment on both CI and the local machine, eliminating redundant CI-only configuration and simplifying the pipeline. That’s how it looks like for GitLab CI using cynerd/gitlab-ci-nix:

.nix:
  image: registry.gitlab.com/cynerd/gitlab-ci-nix
  cache:
    key: "nix"
    paths:
      - ".nix-cache"
  before_script:
    - gitlab-ci-nix-cache-before
  after_script:
    - gitlab-ci-nix-cache-after

pages:
  extends: .nix
  stage: deploy
  script:
    - nix run . -- build -d public && gzip -k -6 $(find public -type f)
  artifacts:
    paths:
      - public

I’m sure there is an analogue image for GitHub CI, but honestly, I haven’t tested any.

Conclusion

Thank you for reading this little article, I hope it will be useful to someone. Feel free to reach me if you have something to say.

And also take a look on the posts on similar topics: