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.
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
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.
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
| Measure | Before | After |
|---|---|---|
| Ruby | 2.7.8 (end of life) | 3.3.5 |
| Jekyll | 3.8.7 | 4.3.4 |
| Build time (cold / warm) | 118 s / 118 s | 51 s / 34 s |
| Pages with changed output, unexplained | — | 0 |
| Broken deep-link anchors | — | 0 (auto_id options pinned) |
| Known-vulnerable gems | 9 | 0 |
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.
Related
- Parent: Jekyll Plugin Ecosystem — the plugins you are upgrading.
- Speeding Up Slow Jekyll Builds — what to do with the time Jekyll 4 saves.
- Running Jekyll on GitHub Pages Without Plugins — version constraints on GitHub Pages.
- Auditing npm Dependencies in SSG Pipelines — the same discipline for JavaScript dependencies.
- Migrating a Docs Site from Jekyll to Hugo — if upgrading reveals it is time to move.