Posted by Em on 16 Jun 2022
Contents
- Background
- Issues with official Docker image
- Creating a Docker configuration from scratch
- Enabling the best possible development experience
- The Solution
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:
- Create content (in Markdown) on a branch
- Merge content to the
main
branch via a Pull Request - GitHub compiles the Markdown into a Jekyll site
- GitHub publishes the compiled content as a static site
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