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.lockincluding thex86_64-linuxplatform (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.
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:
baseurlfor project sites. The classic builder injected it;configure-pagesprovides it now, and templates must userelative_urlfilters.site.githubmetadata. Thejekyll-github-metadataplugin provided repository information; add it explicitly if templates usesite.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
CNAMEfile in the output is harmless but no longer required.
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
| Measure | Classic Pages build (Jekyll 3.10) | Actions workflow (Jekyll 4.3) |
|---|---|---|
| Push to live, median | 4 min 05 s | 1 min 50 s |
| Jekyll build step | 2 min 48 s | 41 s (warm cache) |
| Plugins in use | 6 (allowlist) | 9 |
| Broken deploys in 6 months | 3 (published before anyone noticed) | 0 (blocked by checks) |
| Pull request previews | none | every 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.
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-atreports the checkout time for every page withoutfetch-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_urlso 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.
Related
- Parent: Jekyll Plugin Ecosystem — what plugins cost and offer.
- Deploying to GitHub Pages with Actions — the generator-agnostic version.
- Running Jekyll in Docker for Reproducible Builds — the same build in a container.
- Speeding Up Slow Jekyll Builds — making the build step itself faster.
- Concurrency Groups to Cancel Stale Builds — the concurrency setting explained.
Filed underHow-tosJekyllGitHub ActionsGitHub Pages