Skip to the content.

Posted by Em on 16 Jun 2022

Contents

Background

The very site you are looking at right now is built with GitHub Pages. As an engineering team, we set it up to quickly deliver content about the team and our work. This approach was attractive from the start as it provided an incentive for us to not spend too much time debating architecture and focus purely on content. It’s free for us to use and required no pipeline or deployment strategy to configure.

We started the creation process, which was only a few small steps through the GitHub UI itself. As developers who use GitHub every day, the process felt familiar. In no time at all, we were creating and reviewing content via Pull Requests.

The content itself is served entirely via Markdown files. This proves to be a very powerful approach for formatting content easily, and also lends nicely to keeping the content platform-agnostic. GitHub Pages itself is built to leverage the Jekyll subsystem, which in turn is a Ruby-based architecture. The entire process of publishing content is abstracted away from the developer, which sped up content creation even further.

The standard development process is as follows:

  1. Create content (in Markdown) on a branch
  2. Merge content to the main branch via a Pull Request
  3. GitHub compiles the Markdown into a Jekyll site
  4. GitHub publishes the compiled content as a static site

Visual representation of the development flow outlined above. A horizontal flowchart with starting with 'markdown' at the very left, an arrow pointing right to 'github', with the text 'merge via PR'. From 'github', an arrow pointing right to 'blog', with the text 'compiles and deploys'.

This simple abstraction was proving beneficial, as it meant we didn’t have to worry about all the extra configuration required to create a standard Jekyll site… That was until we needed to do anything more involved than simply posting content. We found very quickly that due to the site not being compiled on local, there were nuances between this and the deployed files. Whilst we could see standard Markdown files locally, when deployed, there would be extra styling and insertion of extra elements.

The obvious solution to this would be to run the entire compiled site locally. To achieve this, we initially followed the official documentation. This involves installing Ruby, Jekyll, and a number of configuration steps. As a team of primarily Node and Python developers, this posed a challenge as we were all unfamiliar with the stack. It also posed challenges to us as a team who develop on different operating systems.

This is where Docker comes in. It would allow us to run the site in complete isolation, without the requirement for developers to have all the extra tooling installed. We all use Docker regularly for our day-to-day development, so this solution would have zero overhead for us to get up and running.

Issues with official Docker image

After a short round of Googling, a number of approaches were suggested by other developers who had also come across this issue. Most notably, there’s even an official pages tag from Jekyll’s available images. This seemed like a good place to start.

I set up a docker-compose.yml file to ease local development overhead. The pages image claimed you didn’t need to add any extra functionality and it should ✨ just work ✨, so at this point it seemed unnecessary to add a Dockerfile.

version: '2'
services:
  pages:
    image: jekyll/jekyll:pages # use jekyll's official `pages` tagged image
    ports:
      - 4000:4000 # map the port 4000 inside the container to the local machine's port 4000, making it accessible from outwith the container
    volumes:
      - .:/srv/jekyll # mount the local file system to allow the container access

In theory, this is all that should have been required. When attempting to run the solution above (via the docker-compose up command), it became clear very quickly that something was wrong. I was hit with a number of errors, e.g.

bundler: failed to load command: jekyll (/usr/local/lib/ruby/gems/3.0.0/bin/jekyll)
/usr/local/lib/ruby/gems/3.0.0/gems/jekyll-3.9.0/lib/jekyll/commands/serve/servlet.rb:3:in `require': cannot load such file -- webrick (LoadError)
...

Upon some investigation, I discovered that I was not alone. Most interestingly, I located this open issue on the GitHub repository for the pages-gem (a dependency used by Jekyll). In short, the issue is that there is a fundamental incompatibility between Ruby version 3 and the pages-gem (at the time of writing) version 255.

Now, the pages Docker image supplied by Jekyll handles the installation of Jekyll, which in turn handles the installation of Ruby. At the time of writing, the latest available image sets the major versions of Jekyll to 3 and Ruby to 3.

Sidenote: Whilst Jekyll version 4 is available, it’s widely documented as not yet supported by the pages-gem.

Due to the base image setting the Ruby version to 3, and the latest supported version being 2.7, we are unable to leverage the base image as provided. We could sit and mess about with downgrading the version of Ruby provided in the base image via additional Dockerfile configuration, but at this point, we’re no longer leveraging the ease of the provided tagged image and we may as well create our own image.

Creating a Docker configuration from scratch

I took a look at what the base pages image from Jekyll provides, and this provided a foundation for creating my own Dockerfile. I started with adding the base image to be Jekyll’s basic version 3 image (jekyll/jekyll:3). I then added a couple of lines I knew I would require (basic dependency installation, working directory setting, and port exposing), which provided the basic Dockerfile:

FROM jekyll/jekyll:3

# copy over the `Gemfile` and its associated lockfile
COPY Gemfile* /srv/jekyll/

# set the working directory to be Jekyll's serving location
WORKDIR /srv/jekyll

# install the dependencies from `Gemfile`
RUN bundle install

# expose the server port
EXPOSE 4000

Running this alone, didn’t quite work (yet). Now I was getting rather useful error messages, stating that I was missing certain dependencies. From here, it became an exercise in trial and error, i.e. receive error message stating a dependency is missing -> add a dependency -> rerun the container -> receive error message stating a dependency is missing -> and so on. Once this exercise was completed, I ended up with the following line to add into the Dockerfile:

RUN apk update && \
	apk add ruby-dev gcc make curl build-base libc-dev libffi-dev zlib-dev libxml2-dev libgcrypt-dev libxslt-dev

This got me over the starting line and I finally had a local replica of the GitHub Pages site locally!

Enabling the best possible development experience

However, I knew there was still room for improvement. The biggest issue with the above setup was that it would require a container restart in order to view new changes in the browser. This would very quickly become a tedious development process.

Helpfully, Jekyll does provide a hot-reloading functionality through the livereload option when passed to Jekyll’s serve command. The way in which Jekyll achieves a hot-reload is through listening to an additional debug port (35729). In order to enable this feature, all that was required here was to expose this additional port from inside the Dockerfile, updating our exposing port line to now be:

EXPOSE 4000 35729

Now alongside updating the serving command to include --livereload, the site could now handle hot-reloading and no longer required a container restart in order to view new content changes in the browser.

The Solution

Now, onto why you’re really here! The final Dockerfile and associated docker-compose.yml (this is optional) file to enable a smooth developer experience for running GitHub Pages locally via Docker ⬇️

# Dockerfile

FROM jekyll/jekyll:3

COPY Gemfile* /srv/jekyll/

WORKDIR /srv/jekyll

RUN apk update && \
	apk add ruby-dev gcc make curl build-base libc-dev libffi-dev zlib-dev libxml2-dev libgcrypt-dev libxslt-dev

RUN bundle install

EXPOSE 4000 35729
# docker-compose.yml

version: '2'
services:
  pages:
    build: .
    ports:
      - 4000:4000
      - 35729:35729
    volumes:
      - .:/srv/jekyll
    command: bundle exec jekyll serve --force_polling --host 0.0.0.0 --incremental --livereload

Back to home