Running Jekyll in Docker for Reproducible Builds
Jekyll's biggest operational problem is not speed or features; it is Ruby. A Jekyll site depends on a particular Ruby version, a particular Bundler, and gems with native extensions that compile against system libraries. On a documentation team with a mix of macOS, Windows and Linux machines, "it builds on my laptop" becomes a weekly conversation: a macOS update breaks the system Ruby, a new writer's Windows machine cannot compile eventmachine, and CI uses yet another Ruby from a setup action. Builds that differ between machines also produce subtly different output — a gem resolved to a different version renders Markdown differently.
This guide packages a Jekyll site's toolchain in a small, pinned Docker image used identically by writers, CI and the production build. It covers the Dockerfile, fast rebuilds through layer caching, live reload for local previews, and CI integration. It is part of Jekyll Plugin Ecosystem. The site is a 1,100-page documentation site with nine plugins, maintained by eleven writers.
Prerequisites
- A Jekyll site with a committed
Gemfile.lock. - Docker Desktop or Docker Engine on each machine, or Podman with the Docker CLI alias.
- A container registry (GitHub Container Registry works well with GitHub Actions).
Step 1: A Minimal, Pinned Image
Base the image on the official Ruby slim image at an exact version, install only the build dependencies the gems need, and install gems from the lockfile:
# Dockerfile
FROM ruby:3.3.5-slim-bookworm
ENV LANG=C.UTF-8 \
BUNDLE_PATH=/usr/local/bundle \
BUNDLE_JOBS=4 \
BUNDLE_WITHOUT=development:test \
JEKYLL_ENV=production
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /site
COPY Gemfile Gemfile.lock ./
RUN gem install bundler -v "$(tail -n1 Gemfile.lock | tr -d ' ')" \
&& bundle install \
&& rm -rf /usr/local/bundle/cache
EXPOSE 4000 35729
CMD ["bundle", "exec", "jekyll", "build"]
COPY Gemfile Gemfile.lock before bundle install is the important line: Docker caches the gem layer and reuses it until the lockfile changes, so rebuilding the image after a content change takes seconds. The Bundler version comes from the lockfile's BUNDLED WITH line, so the image uses exactly the Bundler that created the lock. git is included because jekyll-last-modified-at and similar plugins shell out to it.
The final image was 412 MB; a multi-stage variant that drops build-essential after compiling gems came to 268 MB, which mattered for writers on slow connections pulling updates.
Step 2: Local Preview With Live Reload
Writers run one command. A compose.yaml mounts the site, publishes the ports and enables live reload:
# compose.yaml
services:
docs:
image: ghcr.io/example/docs-jekyll:2026-09
build: .
command: bundle exec jekyll serve --host 0.0.0.0 --livereload --incremental --force_polling
environment:
JEKYLL_ENV: development
ports: ["4000:4000", "35729:35729"]
volumes:
- .:/site
- jekyll-cache:/site/.jekyll-cache
volumes:
jekyll-cache:
docker compose up starts the preview at http://localhost:4000. The named volume for .jekyll-cache keeps Jekyll's own cache (Sass compilation, Liquid template caching) between sessions. --force_polling is needed on macOS and Windows, where file change events do not always cross the volume boundary; it costs a little CPU. With --incremental, a single-page edit regenerated in 1.4 s instead of 21 s for a full rebuild, as described in Speeding Up Slow Jekyll Builds.
Step 3: Build the Image Once, Use It Everywhere
Building the image on every CI run wastes minutes. Build and push it only when the Dockerfile or lockfile changes, tagged with a hash of both, and pull it everywhere else:
# .github/workflows/image.yml
on:
push:
branches: [main]
paths: ['Dockerfile', 'Gemfile', 'Gemfile.lock']
jobs:
image:
runs-on: ubuntu-latest
permissions: { contents: read, packages: write }
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/example/docs-jekyll:${{ hashFiles('Dockerfile', 'Gemfile.lock') }}
cache-from: type=gha
cache-to: type=gha,mode=max
The site build job then runs inside that image as a container job, so there is no Ruby setup step at all:
jobs:
build:
runs-on: ubuntu-latest
container: ghcr.io/example/docs-jekyll:${{ hashFiles('Dockerfile', 'Gemfile.lock') }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: bundle exec jekyll build --trace
- uses: actions/upload-pages-artifact@v3
with: { path: _site }
Step 4: Upgrading Ruby and Gems
Upgrades become ordinary pull requests. Bump the base image tag or run bundle update inside the container (docker compose run --rm docs bundle update jekyll), commit the new lockfile, and CI builds a new image and a preview. Compare the built output with the previous build to see what the upgrade changed, as in Upgrading Jekyll and Ruby Versions Safely. The Ruby 3.1 to 3.3 upgrade on this site was a single pull request with a one-line Dockerfile change and a lockfile update; the output diff showed no changes, and it was merged the same day. Previously, a Ruby upgrade had taken a week of fixing individual laptops.
Platform Differences Worth Knowing
Apple Silicon Macs run linux/arm64 images natively, while CI runners use linux/amd64. Most gems have native builds for both, but a few publish precompiled binaries only for one; build the image for both platforms with platforms: linux/amd64,linux/arm64 in build-push-action so writers on M-series Macs do not run under emulation, which made builds about four times slower in testing. Also add x86_64-linux and aarch64-linux to the lockfile's platforms (bundle lock --add-platform) so Bundler resolves native gems for both.
On Windows, run Docker with the WSL 2 backend and keep the repository inside the WSL filesystem rather than on the Windows drive; volume mounts from C: were slow enough to make incremental rebuilds take 9 s instead of 1.4 s.
Making It Easy for Writers
The container only helps if writers use it, and most writers do not want to learn Docker. Three small things made adoption painless on this team. A Makefile (or npm scripts) with make preview, make build and make check hides the docker compose commands entirely; the README's getting-started section shrank from two pages of Ruby installation notes to three lines. A VS Code dev container definition (.devcontainer/devcontainer.json) pointing at the same image lets writers who use VS Code open the repository and get a working preview with no terminal at all. And a weekly scheduled workflow that rebuilds the image with --pull and runs the site build catches base-image security updates that break something, before a writer discovers it on a Monday morning.
The support channel's Ruby questions stopped entirely within a month, which the team counted as the most valuable outcome of the whole change.
Measured Impact
| Measure | Local Ruby everywhere | Pinned Docker image |
|---|---|---|
| New writer setup time (average) | 95 min | 9 min |
| "Build fails on my machine" support requests per month | 6 | 0.3 |
| CI job duration (median) | 3.1 min | 1.2 min |
| Output differences between a writer's build and CI (sample of 20) | 4 | 0 |
| Ruby upgrade effort (3.1 to 3.3) | ~1 week across laptops | 1 pull request |
Pitfalls & Rollback
- Unpinned base images.
ruby:3-slimchanges under you; pin to an exact version. - Copying the whole site before
bundle install. Every content change rebuilds gems. - Single-platform images on Apple Silicon. Emulation is slow; build multi-arch.
- Bind mounts from the Windows filesystem. Keep the repository inside WSL.
- Rollback: the image is only a packaging of the same toolchain; writers can still run
bundle exec jekyll servelocally, and CI can switch back toruby/setup-rubyby removing the container line.
Conclusion
A small, pinned Docker image turns Jekyll's Ruby dependency from a recurring support issue into a single file under version control. Build it once per dependency change, tag it by lockfile hash, and use it for local previews, pull request builds and production. On this team it cut onboarding from an hour and a half to minutes, halved CI time and made every build produce identical output.
FAQ
Why run Jekyll in Docker instead of installing Ruby locally?
Jekyll depends on a specific Ruby version, Bundler, and gems with native extensions such as sass-embedded, eventmachine and nokogiri. Local installs drift between machines and operating systems, especially on macOS after system updates. A pinned image gives every writer and CI runner the same toolchain with one command.
Is the official jekyll/jekyll image still a good choice?
It has not been updated for current Ruby and Jekyll releases. A small custom image based on the official ruby slim image, with the site's Gemfile.lock installed, is easier to keep current and gives exact control over versions.
How do I keep gem installation fast?
Copy only Gemfile and Gemfile.lock into the image before running bundle install, so the gem layer is cached until dependencies change. In CI, use Docker layer caching or build the image once and push it to a registry.
Does live reload work inside Docker?
Yes. Run jekyll serve with --host 0.0.0.0 and --livereload, publish ports 4000 and 35729, and mount the site directory as a volume. On macOS, add --force_polling if file changes are not detected through the volume mount.
Related
- Parent: Jekyll Plugin Ecosystem — plugins and their operational cost.
- Deploying Jekyll with GitHub Actions — publishing what the container builds.
- Upgrading Jekyll and Ruby Versions Safely — the upgrade process in detail.
- Speeding Up Slow Jekyll Builds — incremental builds and profiling.
- Best SSG for Technical Writers Without Coding Experience — tooling burden in generator choice.