Deploying Jekyll with GitHub Actions

GitHub Pages made Jekyll popular: push Markdown to a branch and a site appears. The classic Pages build that does this is also Jekyll's biggest constraint. It runs a fixed Jekyll version, in safe mode, with a small allowlist of plugins, and it cannot run tests or cache anything. Teams outgrow it the first time they need a plugin that is not on the list, a newer Jekyll or Ruby, or a check that must pass before a broken site is published.

The fix is to keep GitHub Pages as the host and move the build to GitHub Actions. The workflow builds with any Jekyll version and any plugin, caches gems and Jekyll's cache, runs link and HTML checks, and deploys through the official Pages actions. This guide builds that workflow for a 1,100-page documentation site, adds preview deploys for pull requests on another host, and measures the difference. It is part of Jekyll Plugin Ecosystem, and the plugin constraints it removes are described in Running Jekyll on GitHub Pages Without Plugins.

Prerequisites

  • A Jekyll site in a GitHub repository, with a committed Gemfile.lock including the x86_64-linux platform (bundle lock --add-platform x86_64-linux).
  • Admin access to the repository to change the Pages source.
  • Optionally a Docker image of the toolchain, as in Running Jekyll in Docker for Reproducible Builds.

Step 1: Switch the Pages Source

In the repository's Settings → Pages, set Source to GitHub Actions. The site keeps its URL and custom domain; the classic build stops running, and the next deploy must come from a workflow. Remove the github-pages gem from the Gemfile and depend on jekyll directly at the version you want, along with the plugins you actually use:

# Gemfile
source "https://rubygems.org"
gem "jekyll", "~> 4.3"
group :jekyll_plugins do
  gem "jekyll-feed", "~> 0.17"
  gem "jekyll-sitemap", "~> 1.4"
  gem "jekyll-seo-tag", "~> 2.8"
  gem "jekyll-last-modified-at", "~> 1.3"   # not allowed by the classic builder
  gem "jekyll-redirect-from", "~> 0.16"
end

The github-pages gem pinned Jekyll 3.10 and 24 other gems; replacing it with direct dependencies moved the site to Jekyll 4.3, which alone made the build about 40% faster thanks to its caching and Sass improvements.

Step 2: The Workflow

# .github/workflows/pages.yml
name: Build and deploy
on:
  push: { branches: [main] }
  pull_request:
  workflow_dispatch:
permissions: { contents: read, pages: write, id-token: write }
concurrency: { group: pages-${{ github.ref }}, cancel-in-progress: true }

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }                  # jekyll-last-modified-at reads history
      - uses: ruby/setup-ruby@v1
        with: { ruby-version: '3.3.5', bundler-cache: true }
      - uses: actions/cache@v4
        with:
          path: .jekyll-cache
          key: jekyll-${{ hashFiles('Gemfile.lock', '_config.yml') }}-${{ github.sha }}
          restore-keys: jekyll-${{ hashFiles('Gemfile.lock', '_config.yml') }}-
      - id: pages
        uses: actions/configure-pages@v5
      - run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"
        env: { JEKYLL_ENV: production }
      - run: npx --yes html-validate "_site/**/*.html" && npx --yes linkinator _site --recurse --silent
      - uses: actions/upload-pages-artifact@v3
        with: { path: _site }

  deploy:
    if: github.ref == 'refs/heads/main'
    needs: build
    runs-on: ubuntu-latest
    environment: { name: github-pages, url: ${{ steps.deployment.outputs.page_url }} }
    steps:
      - id: deployment
        uses: actions/deploy-pages@v4

bundler-cache: true caches installed gems keyed on the lockfile. The .jekyll-cache step persists Jekyll 4's own cache (rendered Markdown, compiled Sass) between runs with a rolling key. configure-pages provides the correct baseurl for project sites served under /<repo>/. Checks run before upload, so a broken link fails the job and nothing deploys. The deploy job only runs on main; pull requests build and test but do not touch the live site.

GitHub Actions workflow stages for a Jekyll site on GitHub Pages A push starts the build job. Ruby and gems are restored from cache, Jekyll's cache is restored, jekyll build runs, HTML validation and link checks run, and the site is uploaded as a Pages artifact. On main only, the deploy job publishes the artifact to GitHub Pages with the official deploy action. From push to live site Checkout full history Ruby + gems bundler-cache jekyll build .jekyll-cache restored Checks HTML + links Upload artifact _site deploy-pages main only
Pull requests run every step up to the artifact; only main deploys.

Step 3: Preview Deploys on Another Host

GitHub Pages serves one deployment per repository, so pull request previews need somewhere else. The build job already produces _site; add a job that deploys it to Cloudflare Pages (or Netlify) with a branch-specific alias and comments the URL on the pull request:

  preview:
    if: github.event_name == 'pull_request'
    needs: build
    runs-on: ubuntu-latest
    permissions: { pull-requests: write, contents: read }
    steps:
      - uses: actions/download-artifact@v4
        with: { name: github-pages, path: artifact }
      - run: mkdir site && tar -xf artifact/artifact.tar -C site
      - run: npx wrangler pages deploy site --project-name docs-previews --branch "pr-${{ github.event.number }}"
        env: { CLOUDFLARE_API_TOKEN: ${{ secrets.CF_PAGES_TOKEN }}, CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} }

The Pages artifact is a tarball, hence the extraction step. Build previews without the Pages baseurl if the preview host serves from the root, or build twice with different settings. Comment handling and cleanup follow Automating Preview Deploy Pipelines with GitHub Actions.

What the Classic Builder Did That You Now Own

The classic build did a few things silently that are easy to lose in the move:

  • baseurl for project sites. The classic builder injected it; configure-pages provides it now, and templates must use relative_url filters.
  • site.github metadata. The jekyll-github-metadata plugin provided repository information; add it explicitly if templates use site.github.*.
  • Safe mode. Custom plugins in _plugins/ now run, which is the point, but also means reviewing them like any other code.
  • CNAME file. Custom domains are configured in repository settings; a CNAME file in the output is harmless but no longer required.
Capabilities of the classic GitHub Pages Jekyll build and an Actions workflow A matrix comparing the classic Pages build with an Actions workflow. Jekyll version is fixed at 3.10 in the classic build and any version with Actions. Plugins are limited to an allowlist versus any gem. Caching is none versus gems and Jekyll cache. Checks before deploy are impossible versus any. Build time was about 4 minutes versus 1 minute 50 seconds. Classic Pages build versus Actions workflow Capability Classic Pages build Actions workflow Jekyll version 3.10 fixed any, pinned in lockfile Plugins allowlist only any gem or _plugins/ Caching between builds none gems + .jekyll-cache Checks before deploy none HTML, links, anything Build to live about 4 min 1 min 50 s
Hosting, URL and certificate stay the same; everything about the build changes.

Keeping Pull Requests Fast

On busy documentation repositories, several pull requests often update at once, and each push starts a build. The concurrency group in the workflow cancels an in-progress build for the same branch when a newer commit arrives, so only the latest commit is built and deployed. Path filters keep changes that cannot affect the site — a README edit, a workflow tweak for another job — from triggering builds at all. Together these reduced the monthly Actions minutes for the repository by about 35% without making anyone wait longer for their own preview. For writers, the pull request check list becomes the feedback loop: the build, the checks and the preview URL appear in one place, and a red check links straight to the failing page and rule.

Measured Impact

MeasureClassic Pages build (Jekyll 3.10)Actions workflow (Jekyll 4.3)
Push to live, median4 min 05 s1 min 50 s
Jekyll build step2 min 48 s41 s (warm cache)
Plugins in use6 (allowlist)9
Broken deploys in 6 months3 (published before anyone noticed)0 (blocked by checks)
Pull request previewsnoneevery pull request

The three broken deploys under the classic build were a Liquid error that rendered an empty home page, a renamed include that broke every sidebar, and a front matter typo that dropped a section from navigation; the HTML and link checks would have blocked all three.

Minutes from push to live site with each build method Vertical bars of median minutes from push to the live site. The classic GitHub Pages build took 4.1 minutes. The Actions workflow with a cold cache took 3.0 minutes. The Actions workflow with warm gem and Jekyll caches took 1.8 minutes. Push to live 4.1 min Classic Pages build 3.0 min Actions, cold cache 1.8 min Actions, warm cache
Median of 40 deploys per configuration, 1,100-page site.

Rolling Back

GitHub Pages keeps previous deployments in the github-pages environment's history, but there is no one-click rollback in the Pages UI. The reliable rollback is re-running the deploy workflow for an earlier commit: gh workflow run pages.yml --ref <good-sha> rebuilds and redeploys that commit in under two minutes. For faster rollbacks, keep the last few Pages artifacts (they are retained for 90 days by default) and add a small workflow that redeploys a chosen artifact without rebuilding; the general approach is in Rolling Back a Bad Static Deploy in Under a Minute.

Pitfalls & Rollback

  • Shallow checkout with date plugins. jekyll-last-modified-at reports the checkout time for every page without fetch-depth: 0.
  • Missing Linux platform in the lockfile. Bundler fails on the runner; add x86_64-linux.
  • Hard-coded paths in project sites. Use relative_url so the /repo/ prefix works.
  • Deploying from pull requests. Guard the deploy job with a branch condition.
  • Rollback: switching Settings → Pages back to "Deploy from a branch" restores the classic build immediately if the workflow approach must be abandoned.

Conclusion

Moving a Jekyll site's build from the classic GitHub Pages builder to GitHub Actions keeps the host and URL while removing every build constraint: any Jekyll, any plugin, caching, checks before publishing and previews elsewhere. On this site it halved the time to live, unlocked a newer Jekyll and three plugins, and stopped broken builds from reaching readers.

FAQ

Why not use the built-in GitHub Pages Jekyll build?

The classic Pages builder runs a fixed Jekyll version with a short allowlist of plugins in safe mode. A GitHub Actions workflow can use any Jekyll version and any plugin, cache gems and Jekyll's cache, run tests before deploying, and still publish to GitHub Pages.

Does deploying with Actions change my site's URL?

No. Switching the Pages source from a branch to GitHub Actions keeps the same domain, custom domain and HTTPS certificate. Only the build process changes.

How long does a Jekyll build take in GitHub Actions?

For the 1,100-page site measured here, about 1 minute 50 seconds end to end with gem and Jekyll caches, compared with around 4 minutes for the classic Pages builder, which caches nothing between builds.

Can I get preview deploys for pull requests with GitHub Pages?

GitHub Pages serves one deployment per repository, so pull request previews need another host, such as Cloudflare Pages, Netlify or a separate repository. The same workflow can build once and deploy the pull request build to the preview host.

Filed underHow-tosJekyllGitHub ActionsGitHub Pages