Running Jekyll on GitHub Pages Without Plugins
GitHub Pages' classic build runs Jekyll in safe mode with a short allowlist of gems. Anything outside that list is not an error — it is silently ignored, which is the worst possible failure mode: the site builds, deploys and is missing whatever the plugin was producing.
There are two good answers. Replace the plugin behaviour with things safe mode allows: Liquid, data files, and a pre-build script that commits its output. Or take the GitHub Actions route, which removes the restriction entirely. This guide covers both, and when each is right. It is part of Jekyll Plugin Ecosystem.
Prerequisites
- A Jekyll site currently building on GitHub Pages, or intended to.
- Local Jekyll matching the Pages version (
gem 'github-pages', group: :jekyll_plugins) so local and remote agree. - A list of the plugins you actually rely on, and what each one produces.
Know What the Allowlist Costs You
Verify rather than assume, by diffing what you build locally against what Pages serves:
bundle exec jekyll build --destination local-site
find local-site -name '*.html' | sed 's|local-site||' | sort > local-urls.txt
curl -s https://example.github.io/sitemap.xml | grep -oE '<loc>[^<]+' \
| sed 's|<loc>https://example.github.io||' | sort > live-urls.txt
comm -23 local-urls.txt live-urls.txt
Anything printed is a page your plugins generate locally and the classic build does not.
Replace Plugins With Liquid and Data
Most plugins on a documentation site transform content that already exists. Liquid handles that in safe mode.
Table of contents — a plugin is unnecessary; Kramdown emits one:
* TOC placeholder
{:toc}
Related posts by tag — grouping and filtering are Liquid operations:
{% assign related = site.posts
| where_exp: 'p', 'p.url != page.url'
| where_exp: 'p', 'p.tags contains page.tags.first'
| slice: 0, 4 %}
<ul>
{% for post in related %}<li><a href="{{ post.url }}">{{ post.title }}</a></li>{% endfor %}
</ul>
Reading time — arithmetic on the content:
{% assign words = page.content | number_of_words %}
{% assign minutes = words | divided_by: 220 | plus: 1 %}
<span class="reading-time">{{ minutes }} min read</span>
Category or archive pages — the classic build cannot generate pages from a plugin, but it can render pages that exist. Commit one stub per category and let Liquid fill it:
---
layout: category
title: Deployment
category: deployment
permalink: /categories/deployment/
---
That stub is three lines, and generating the stubs is itself a job for a pre-build script.
Use a Pre-Build Script for Anything Generative
Whatever Liquid cannot do, a script can — as long as it runs before the commit rather than during the Pages build. Generate files, commit them, and let Jekyll treat them as ordinary content:
// scripts/gen-category-stubs.mjs — run locally or in CI, commit the output
import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
import matter from 'gray-matter';
const cats = new Set();
for (const file of await readdir('_posts')) {
const { data } = matter(await readFile(`_posts/${file}`, 'utf8'));
for (const c of data.categories ?? []) cats.add(c);
}
await mkdir('categories', { recursive: true });
for (const c of cats) {
await writeFile(`categories/${c}.md`,
`---\nlayout: category\ntitle: ${c}\ncategory: ${c}\npermalink: /categories/${c}/\n---\n`);
}
console.log(`generated ${cats.size} category stub(s)`);
This is the general escape hatch: any plugin that generates files can become a script that generates the same files, committed to the repository. The site stays buildable by the classic Pages build, and the generation is visible in the diff — which is arguably better than a plugin nobody remembers configuring.
Or Just Build With Actions
The allowlist exists because GitHub runs the classic build on its own infrastructure. Build it yourself and the restriction disappears:
# .github/workflows/pages.yml
name: Deploy Jekyll
on:
push: { branches: [main] }
permissions: { contents: read, pages: write, id-token: write }
concurrency: { group: pages, cancel-in-progress: false }
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with: { ruby-version: '3.3', bundler-cache: true }
- uses: actions/configure-pages@v5
- run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"
env: { JEKYLL_ENV: production }
- uses: actions/upload-pages-artifact@v3
with: { path: ./_site }
deploy:
needs: build
runs-on: ubuntu-latest
environment: { name: github-pages, url: '${{ steps.deployment.outputs.page_url }}' }
steps:
- id: deployment
uses: actions/deploy-pages@v4
Decide Plugin by Plugin
Before choosing a route, classify each plugin you use. The census takes ten minutes and usually shrinks the problem to two or three genuine cases.
Write the classification down in the repository next to the Gemfile. It is the document that stops the same investigation happening again in a year, and it makes the eventual decision about hosting a one-line summary rather than an argument.
Measured Impact
A 340-page documentation site that had been on the classic build for three years:
| Measure | Classic build | Actions build |
|---|---|---|
| Pages produced | 297 | 340 |
| Plugins actually running | 5 of 11 | 11 of 11 |
| Jekyll version | pinned by Pages | current |
| Build time | ~35 s (opaque) | 48 s (visible log) |
| Preview per pull request | none | yes |
The first row is the story: 43 category and archive pages were being generated locally, appearing in every local preview, and had never existed in production. Nobody had noticed because the pages were reachable from a navigation menu that was itself generated by the same missing plugin.
Keep Local and Remote in Agreement
Whichever route you take, the second-largest source of surprise is a Jekyll version difference between your machine and the build. On the classic build, pin to the meta-gem so Bundler installs exactly what GitHub runs:
# Gemfile
source 'https://rubygems.org'
gem 'github-pages', group: :jekyll_plugins
On the Actions route the equivalent discipline is pinning Ruby and committing Gemfile.lock, so the runner installs the same dependency graph every time. Either way, the goal is that a build succeeding locally is evidence about the build that will run, rather than a coincidence.
Add a check that fails when the two diverge — comparing the page count from a local build against the live sitemap is crude, fast and catches the whole class of silent-drop problems:
LOCAL=$(find local-site -name '*.html' | wc -l)
LIVE=$(curl -s https://example.github.io/sitemap.xml | grep -c '<loc>')
[ "$LOCAL" -eq "$LIVE" ] || { echo "page count differs: local $LOCAL, live $LIVE"; exit 1; }
Pitfalls & Rollback
- Assuming a silent build is a correct build. Diff local output against the live sitemap before trusting either.
- Version drift. Without the
github-pagesgem locally, your Jekyll differs from the server's and behaviour diverges quietly. - Committing generated files without a generator. If a script produced them, keep the script in the repository or the next person cannot regenerate them.
- Migrating hosting and dependencies at once. Move to Actions first with the same gems, then change gems.
- Forgetting
baseurl. A project site served from a subpath needs it set correctly or every asset 404s. - Rollback: the classic build is still there. Deleting the workflow and re-enabling branch-based publishing restores the previous behaviour — including, unfortunately, the missing pages.
Conclusion
Safe mode's allowlist is a constraint worth understanding rather than fighting: Liquid covers most content transformations, and a committed pre-build script covers file generation. But if you rely on more than a couple of plugins, the Actions route is a single workflow file that removes the constraint and adds previews, build logs and content gates in the process. Start by diffing local against live — the gap is usually larger than expected. The wider ecosystem picture is in Jekyll Plugin Ecosystem.
FAQ
Which plugins does GitHub Pages actually allow?
A short allowlist maintained by GitHub, covering sitemap generation, SEO tags, feeds, redirects, mentions, gists and a handful of others. Anything outside it is ignored silently on the classic Pages build, which is why a site can work locally and be missing content when deployed.
Why does my plugin work locally but not on GitHub Pages?
Because the classic Pages build runs in safe mode, which loads only allowlisted gems. Your local build has no such restriction, so a custom generator or a third-party gem runs happily on your machine and is skipped entirely on the server.
Should I just use GitHub Actions instead?
For most sites, yes. Building with Actions and deploying the output removes the allowlist entirely, gives you any gem and any Jekyll version, and costs a workflow file. Stay on the classic build only when you value having no pipeline at all.
Can Liquid really replace a plugin?
Often, for anything that transforms content you already have — grouping, filtering, related lists, tables of contents, reading time. It cannot replace plugins that fetch data or invoke external tools, which is where a pre-build script comes in.
What is the safest migration path off the allowlist?
Move to a GitHub Actions build first, changing nothing else. Once the build is yours, add or remove gems deliberately. Changing hosting and dependencies at the same time makes every failure ambiguous.
Related
- Parent: Jekyll Plugin Ecosystem — what the plugins do and which are worth keeping.
- Replacing Jekyll Plugins When Migrating to Eleventy — the other way out of the allowlist.
- Migrating a Docs Site From Jekyll to Hugo — if you are leaving Jekyll entirely.
- GitHub Actions for Automated SSG Builds — the pipeline the Actions route gives you.
- Eleventy vs Jekyll for Markdown-Heavy Blogs — the comparison behind the migration question.