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

What safe mode allows and what it silently drops Two columns. Allowed: sitemap, SEO tag, feed, redirect-from, mentions, gist, avatar, and paginate. Silently ignored: custom generators, archive plugins, image processing, table of contents plugins, asset pipelines and any third-party gem. A note explains that ignored plugins produce no error, only missing output. The failure mode is silence, not an error Allowed on the classic build jekyll-sitemap jekyll-seo-tag jekyll-feed jekyll-redirect-from jekyll-paginate …and a handful of smaller ones Ignored without warning any custom _plugins/ generator archive and category plugins image resizing gems table-of-contents plugins asset pipelines no build error — just missing pages
A dropped generator plugin does not fail the build; it produces a site with fewer pages than expected. That is why the first step is comparing local output against deployed output, file by file.

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
Classic Pages build versus an Actions build Two paths. The classic build takes a push straight to GitHub's own Jekyll build in safe mode, which allows only allowlisted gems and silently drops the rest. The Actions build runs your own Jekyll with any gems and any version, uploads the artifact, and deploys it to Pages. One workflow file removes the whole constraint Classic git push GitHub builds, safe mode allowlisted gems only published Actions git push your build, your gems any Jekyll version artifact → published The Actions route also gives you preview builds, content gates and a build log — none of which the classic build offers
The classic build's only advantage is having no pipeline. Everything else — gems, versions, previews, checks — is on the other side of one workflow file.

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.

Four outcomes for each plugin A decision matrix. If the plugin is allowlisted, keep it. If it transforms existing content, replace it with Liquid. If it generates files, replace it with a committed pre-build script. If it needs a gem or external tool at build time, that is the case that requires an Actions build. Only the last box forces a decision about hosting Allowlisted → keep sitemap, feed, seo-tag, redirect-from no action needed at all Transforms content → Liquid related posts, reading time, TOC a few lines in a layout Generates files → script category stubs, archive pages run it, commit the output Needs a gem → Actions image processing, asset pipelines the only genuinely blocking case
On the site measured below, eleven plugins classified as five allowlisted, three replaceable with Liquid, two script-generated and one that genuinely needed a gem — which was enough on its own to justify the Actions build.

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:

MeasureClassic buildActions build
Pages produced297340
Plugins actually running5 of 1111 of 11
Jekyll versionpinned by Pagescurrent
Build time~35 s (opaque)48 s (visible log)
Preview per pull requestnoneyes

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-pages gem 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.