Upgrading Jekyll and Ruby Versions Safely

Many Jekyll sites run on whatever versions they were created with: Jekyll 3.8, Ruby 2.7, a Gemfile without a lockfile, plugins last updated years ago. They keep building until the day a CI image drops the old Ruby, a host's build environment updates, or a gem's native extension refuses to compile on a new operating system — and then they fail completely, usually right before a release.

Upgrading is not difficult, but it is easy to do badly: bumping everything at once, seeing the build succeed, and shipping pages whose Markdown now renders slightly differently. This guide upgrades a 1,400-page Jekyll 3.8 documentation site on Ruby 2.7 to Jekyll 4.3 on Ruby 3.3 in four small steps, diffing the built output after each so every change is deliberate. It is part of Jekyll Plugin Ecosystem.

Prerequisites

  • The site in Git, building successfully on its current versions (capture that output first — it is the baseline).
  • A way to install multiple Ruby versions side by side: rbenv, asdf, mise, or Docker images.
  • Time for an afternoon of reviewing diffs; the mechanical steps take minutes.

Step 0: Capture a Baseline

Build with the current versions and keep the output. Everything afterwards is compared against it.

ruby -v                       # ruby 2.7.8
bundle exec jekyll -v         # jekyll 3.8.7
bundle exec jekyll build -d /tmp/baseline
git add Gemfile.lock 2>/dev/null || bundle lock && git add Gemfile.lock

If there was no Gemfile.lock, create and commit one now, pinned to what currently works. Also note the build time — the upgrade should improve it.

Step 1: Upgrade Ruby, Keep Jekyll

Move to a supported Ruby while holding Jekyll and plugins at their current versions. Ruby 3.x removed some standard-library gems from the default set (webrick for jekyll serve, kramdown-parser-gfm for GFM input in newer Kramdown), so the Gemfile may need them explicitly:

# Gemfile (step 1)
source 'https://rubygems.org'
gem 'jekyll', '3.9.5'            # 3.9 is the last 3.x, supports Ruby 3
gem 'kramdown-parser-gfm'
gem 'webrick', '~> 1.8'
group :jekyll_plugins do
  gem 'jekyll-feed', '0.15.1'
  gem 'jekyll-seo-tag', '2.8.0'
  gem 'jekyll-redirect-from', '0.16.0'
end
echo '3.3.5' > .ruby-version
bundle install && bundle exec jekyll build -d /tmp/step1
diff -rq /tmp/baseline /tmp/step1 | wc -l

The diff showed 3 changed files: the sitemap and feed timestamps, and one page where an old Liquid filter's output changed under Ruby 3's keyword-argument semantics. Each difference was read and accepted or fixed before moving on.

Four upgrade steps, each with a diff gate A sequence from the baseline Jekyll 3.8 on Ruby 2.7 to Ruby 3.3 with Jekyll 3.9, then Jekyll 4.3, then updated plugins, then locked and automated. After each step the built output is diffed against the previous step and every change is reviewed before continuing. Change one thing, diff, then change the next baseline Jekyll 3.8 · Ruby 2.7 1 · Ruby 3.3 + Jekyll 3.9 2 · Jekyll 4.3 3 · plugins current versions 4 · lock + automate 3 files differ 214 files differ 41 files differ 0 diff -r previous-step current-step → review every file One cause per diff means every changed page can be explained
Doing all three upgrades at once would have produced one diff of about 250 files with three mixed causes.

Step 2: Upgrade Jekyll to 4.x

gem 'jekyll', '~> 4.3'

This step produced the largest diff — 214 files — and almost all of it had one cause: Jekyll 4 uses a newer Kramdown whose default settings differ slightly, notably in automatic header IDs for headings containing punctuation and in smart quotes inside code spans. Rather than accept silently changed anchor IDs (which break deep links from other sites), the site pinned the old behaviour:

# _config.yml
kramdown:
  input: GFM
  auto_ids: true
  auto_id_stripping: true
  hard_wrap: false
  syntax_highlighter: rouge

With those options the diff fell to 17 files, each a genuine improvement (correctly escaped HTML in two tables, fixed smart quotes) and accepted. Jekyll 4 also no longer bundles some plugins by default, and site.related_posts behaviour and Liquid's handling of nil in comparisons changed in minor ways; the diff surfaces each.

Build time dropped from 118 s to 52 s at this step, thanks to Jekyll 4's render cache in .jekyll-cache/, the improvement that also underpins Speeding Up Slow Jekyll Builds.

Reviewing a Large Diff Efficiently

Two hundred changed files sounds like a day of reading, but diffs from a version upgrade are highly repetitive: the same change applied to many pages. Group them before reading any. Strip the parts that are expected to differ (build timestamps, cache-busting hashes), then normalise whitespace and hash each file's remaining diff; identical diff hashes are the same change on different pages.

for f in $(diff -rq /tmp/step1 /tmp/step2 | awk '{print $2}' | sed 's#/tmp/step1/##'); do
  diff <(sed -E 's/[0-9]{4}-[0-9]{2}-[0-9]{2}T[^"<]+//g' /tmp/step1/$f) \
       <(sed -E 's/[0-9]{4}-[0-9]{2}-[0-9]{2}T[^"<]+//g' /tmp/step2/$f) | sha1sum | cut -c1-8
done | sort | uniq -c | sort -rn
Grouping the Jekyll 4 diff by distinct change 214 changed files collapse into 6 distinct change patterns. 162 files share a heading ID change, 28 share a smart-quote change in code spans, 11 share a table escaping fix, 8 share a footnote markup change, 3 a feed timestamp, and 2 are unique changes needing individual review. 214 changed files, 6 distinct changes heading IDs · 162 quotes 28 tables 11 · footnotes 8 · feed 3 · unique 2 Read one example of each pattern, then the 2 unique files: 8 files instead of 214 Diffs hashed after stripping timestamps; identical hashes = identical change
Grouping turned a day of reading into twenty minutes, and made the heading-ID pattern impossible to miss.

Reading one example of each group plus the unique files took about twenty minutes. It is also how the heading-ID change was spotted as the dominant pattern rather than scattered noise, which led directly to pinning the Kramdown options instead of accepting 162 pages of changed anchors.

Step 3: Upgrade Plugins

Update each plugin to its current release, one at a time for any that changes output:

bundle update jekyll-feed jekyll-seo-tag jekyll-redirect-from --conservative
bundle exec jekyll build -d /tmp/step3 && diff -rq /tmp/step2 /tmp/step3

jekyll-seo-tag's newer JSON-LD output changed 41 files — structured data only, validated with a schema checker before acceptance. One unmaintained plugin (a custom category generator last updated in 2017) failed on Jekyll 4's internals and was replaced with a 20-line hook, the approach described in Replacing Jekyll Plugins When Migrating to Eleventy for a different destination.

Step 4: Lock and Automate

# .github/workflows/build.yml (excerpt)
- uses: ruby/setup-ruby@v1
  with: { bundler-cache: true }          # reads .ruby-version, caches gems by Gemfile.lock
- run: bundle exec jekyll build

ruby/setup-ruby reads .ruby-version, so local development and CI use the same Ruby. A dependency bot opens pull requests for gem updates; a CI job builds both the base branch and the pull request and posts the count of changed files, so a "patch" update that changes 300 pages gets noticed before merge.

Build time across the upgrade Bars of build time: 118 seconds on the baseline, 109 after upgrading Ruby, 52 after Jekyll 4, 51 after plugin updates, and 34 on a warm build with the Jekyll cache restored in CI. Build seconds at each step (1,400 pages) 118 109 52 51 34 baseline Ruby 3.3 Jekyll 4.3 plugins warm cache GitHub Actions ubuntu-latest, median of 3
The upgrade that was justified by security also more than halved build time.

Native Extensions and Containers

The step most likely to fail outright is not Jekyll at all but gems with native extensions — nokogiri, eventmachine, ffi, sassc — compiled against the system's C libraries. On a new Ruby or a new operating system image they may need newer versions, or precompiled platform gems. Two measures avoid surprises. Add every platform you build on to the lockfile (bundle lock --add-platform x86_64-linux aarch64-linux arm64-darwin), so Bundler can pick precompiled gems instead of compiling. And build in a pinned container image or ruby/setup-ruby with an exact Ruby version rather than whatever the runner provides, so an image update cannot change the compiler underneath you. On this site, replacing the long-deprecated sassc with sass-embedded (which Jekyll 4.3's Sass converter supports) removed the only gem that needed a C compiler at all, and cut bundle install on a cold CI cache from 3 minutes to 40 seconds.

Measured Impact

MeasureBeforeAfter
Ruby2.7.8 (end of life)3.3.5
Jekyll3.8.74.3.4
Build time (cold / warm)118 s / 118 s51 s / 34 s
Pages with changed output, unexplained0
Broken deep-link anchors0 (auto_id options pinned)
Known-vulnerable gems90

Pitfalls & Rollback

  • Upgrading everything at once. Mixed causes make the diff unreviewable. One change per step.
  • Accepting changed heading IDs. They break inbound anchor links; pin Kramdown options or add redirects for anchors that must change.
  • No lockfile. Without Gemfile.lock, CI and local builds resolve different versions.
  • Forgetting the host's build. If GitHub Pages builds the site, its allowed Jekyll version governs; build in Actions to choose your own.
  • Rollback: each step is one commit; reverting the last commit restores the previous working combination.

Conclusion

A safe Jekyll upgrade is a sequence of small, diffed steps: capture a baseline, move Ruby with Jekyll held back, move Jekyll with Kramdown options pinned, update plugins one by one, then lock versions and automate future updates. On a 1,400-page site that took an afternoon, left no unexplained change in any page, removed nine vulnerable gems and cut the build from 118 to 34 seconds.

FAQ

Why upgrade an old Jekyll site that still builds?

Old Ruby versions stop receiving security fixes and eventually disappear from CI images and hosting build environments, at which point the site stops building with little warning. Jekyll 4 is also considerably faster thanks to its render cache.

What breaks when moving from Jekyll 3 to Jekyll 4?

Mostly plugins that relied on internal APIs, a few Liquid behaviour changes, the removal of some default gems from core, and Kramdown option differences. Page output usually changes in small ways, which a diff of the built site reveals.

Should I upgrade Ruby and Jekyll at the same time?

No. Upgrade one thing per step and diff the output after each, so any change in the site can be traced to a single cause. Ruby first on the old Jekyll if possible, then Jekyll, then plugins.

How do I keep versions from drifting again?

Commit Gemfile.lock, pin Ruby with a .ruby-version file used by both local tools and CI, and let a dependency bot open small update pull requests with a diff-checking CI job.