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.

Docker image layers for a Jekyll site Four layers from bottom to top. The Ruby 3.3.5 slim base image changes only when the Ruby version is bumped. The apt build dependencies layer changes rarely. The bundle install layer is invalidated only when Gemfile.lock changes. The site content is mounted or copied at build time and changes constantly without invalidating the layers below. Image layers and what invalidates them Site content mounted or copied last changes every commit bundle install from Gemfile.lock rebuilt when the lockfile changes apt build deps build-essential, git rarely changes ruby:3.3.5-slim-bookworm pinned base changes on Ruby upgrades
Content edits never rebuild gems; only a lockfile change does.

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 }
How the Jekyll image is built once and used by writers and CI A lockfile change on main triggers the image workflow, which builds and pushes an image tagged with the lockfile hash to the registry. Writers pull the image and run the preview with docker compose. Pull request builds and production builds run as container jobs in the same image and publish the site. One image, three consumers Image workflow on lockfile change GHCR registry Writers docker compose up CI builds container jobs push image tagged with lockfile hash pull once per dependency change pull cached image per job jekyll build in identical toolchain
The image tag is derived from the Dockerfile and lockfile, so every consumer of a given commit uses the same toolchain.

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.

Onboarding and build times with local Ruby and with Docker Paired bars. A new writer's setup took 95 minutes on average with local Ruby and 9 minutes with Docker, mostly the image pull. The CI job took 3.1 minutes with ruby setup and bundle install and 1.2 minutes in the prebuilt container. Setup and build times before and after Local Ruby Docker image New writer setup 95 min 9 min CI job total 3.1 min 1.2 min
Onboarding time averaged over the last six writers to join under each setup; CI times are medians of 50 runs.

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

MeasureLocal Ruby everywherePinned Docker image
New writer setup time (average)95 min9 min
"Build fails on my machine" support requests per month60.3
CI job duration (median)3.1 min1.2 min
Output differences between a writer's build and CI (sample of 20)40
Ruby upgrade effort (3.1 to 3.3)~1 week across laptops1 pull request

Pitfalls & Rollback

  • Unpinned base images. ruby:3-slim changes 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 serve locally, and CI can switch back to ruby/setup-ruby by 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.

Filed underHow-tosJekyll