How to Automate Jekyll Builds with GitHub Actions
Set up GitHub Actions to automatically build and deploy your Jekyll site β with gem caching, HTML validation, link checking, and multi-environment deployments.
GitHub Pages has a built-in Jekyll build system, and for simple sites it is all you need. But the moment you want a plugin that is not on GitHubβs approved list β jekyll-archives, jekyll-toc, jekyll-paginate-v2, or any of dozens of others β you hit a wall. The solution is GitHub Actions: a CI/CD system built directly into GitHub that can run any build command, any plugin, any Ruby version, and deploy the result wherever you want.
Beyond unlocking plugins, GitHub Actions gives you something equally valuable: a proper deployment pipeline. You can build once and validate before deploying, catch broken links or invalid HTML before they go live, preview pull requests on a staging URL, and maintain multiple deployment targets. This guide covers everything from a minimal working workflow to a full production pipeline.
Why use GitHub Actions for Jekyll?
Unlimited plugins. The built-in GitHub Pages builder uses a restricted whitelist of gems. GitHub Actions builds your site yourself, so you can use any gem in your Gemfile.
Any Ruby version. The built-in builder uses whatever Ruby version GitHub has configured. With Actions, you specify the exact version β pin it to match your local development environment for reproducible builds.
Build validation. Run HTML validation and link checkers against your built site before deploying. Catch problems before readers see them.
Multiple deploy targets. Deploy the same built site to GitHub Pages, Netlify, Cloudflare Pages, AWS S3, or any other host. You are not locked to one platform.
Preview deployments. Deploy pull requests to a temporary URL. Review changes visually before merging into production.
Scheduled builds. Rebuild your site on a schedule β useful for sites that display fresh data from external sources like APIs or GitHub star counts.
Prerequisites
You need a Jekyll site in a GitHub repository with:
- A
Gemfilelisting your dependencies - A
Gemfile.lockcommitted to the repository (crucial for reproducible builds) - GitHub Pages enabled in the repository settings (Settings β Pages β Source: GitHub Actions)
The basic workflow
Create .github/workflows/deploy.yml in your repository. This directory and file do not exist by default β create them:
name: Build and Deploy Jekyll
on:
push:
branches:
- main # Deploy on every push to main
pull_request:
branches:
- main # Build (but don't deploy) on pull requests
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true # Runs bundle install and caches gems automatically
- name: Configure Pages
id: pages
uses: actions/configure-pages@v5
- name: Build Jekyll site
run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"
env:
JEKYLL_ENV: production
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: _site/
deploy:
needs: build
if: github.ref == 'refs/heads/main' # Only deploy from main, not PRs
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Push this file to your repository and GitHub Actions will pick it up automatically. The workflow runs on every push to main and on every pull request, but only deploys on pushes to main.
Understanding each step
actions/checkout@v4 clones your repository into the runnerβs working directory. Always the first step.
ruby/setup-ruby@v1 installs the specified Ruby version and, with bundler-cache: true, automatically runs bundle install and caches the installed gems. The cache key is based on your Gemfile.lock, so gems are reinstalled only when your dependencies change. This typically turns a 2β3 minute build into a 20β30 second one after the first run.
actions/configure-pages@v5 sets up the Pages environment and provides the base_path output, which your Jekyll build needs if your site is hosted at a subdirectory URL (e.g. yourusername.github.io/repo-name/).
bundle exec jekyll build runs the Jekyll build. JEKYLL_ENV: production enables production-only features, such as Google Analytics scripts that are conditionally included.
actions/upload-pages-artifact@v3 packages the _site/ directory as an artifact that the deploy job can consume.
actions/deploy-pages@v4 takes the uploaded artifact and deploys it to GitHub Pages.
Gem caching for faster builds
The bundler-cache: true option handles caching automatically. For additional control, you can manage the cache explicitly:
- name: Cache Ruby gems
uses: actions/cache@v4
with:
path: vendor/bundle
key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }}
restore-keys: |
${{ runner.os }}-gems-
- name: Install dependencies
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
Add vendor/ to your .gitignore to prevent committing installed gems:
vendor/
.bundle/
Adding HTML validation
Validate your built HTML before deploying. This catches broken markup, missing alt text on images, and malformed links:
- name: Validate HTML with HTMLProofer
run: |
bundle exec htmlproofer ./_site \
--disable-external \
--checks Links,Images,Scripts \
--allow-missing-href true \
--ignore-urls "/^http:\/\/localhost/"
Add html-proofer to your Gemfile:
group :development, :test do
gem "html-proofer"
end
HTMLProofer checks that:
- All internal links resolve to real pages
- All images have alt text
- All
<script>srcattributes are valid - The HTML structure is well-formed
The --disable-external flag skips checking external URLs, which would make your build very slow. Handle external link checking separately.
External link checking on a schedule
External links go stale over time β pages get deleted, domains expire, companies shut down. Run an external link check weekly rather than on every push:
name: Weekly Link Check
on:
schedule:
- cron: '0 8 * * 1' # Every Monday at 8am UTC
workflow_dispatch: # Also allows manual runs
jobs:
link-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Build Jekyll
run: bundle exec jekyll build
env:
JEKYLL_ENV: production
- name: Check external links
run: |
bundle exec htmlproofer ./_site \
--checks Links \
--external-only \
--ignore-urls "/twitter\.com/,/linkedin\.com/,/web\.archive\.org/"
Common domains to ignore: Twitter/X and LinkedIn block automated requests and return 403 errors even for valid pages. Add them to --ignore-urls to avoid false positives.
Preview deployments for pull requests
Deploy pull requests to a temporary preview URL so you can review changes visually before merging. This is one of the most useful features for teams:
name: Pull Request Preview
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
- name: Build Jekyll
run: bundle exec jekyll build
env:
JEKYLL_ENV: staging
- name: Deploy preview to Netlify
uses: nwtgck/actions-netlify@v3
with:
publish-dir: './_site'
github-token: ${{ secrets.GITHUB_TOKEN }}
deploy-message: "PR #${{ github.event.number }}: ${{ github.event.pull_request.title }}"
enable-pull-request-comment: true
enable-commit-comment: false
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
The action posts a comment on the pull request with the preview URL. Anyone reviewing the PR can click the link to see exactly what the site will look like if merged.
Deploying to Netlify for production
If you prefer Netlify over GitHub Pages for production (for its CDN, forms, or analytics):
- name: Deploy to Netlify (production)
if: github.ref == 'refs/heads/main'
uses: nwtgck/actions-netlify@v3
with:
publish-dir: './_site'
production-branch: main
production-deploy: true
github-token: ${{ secrets.GITHUB_TOKEN }}
deploy-message: "Deploy from GitHub Actions: ${{ github.event.head_commit.message }}"
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
Get NETLIFY_AUTH_TOKEN from your Netlify account settings (User settings β Applications β Personal access tokens). The NETLIFY_SITE_ID is the API ID shown in your site settings. Add both as repository secrets in GitHub Settings β Secrets and variables β Actions β New repository secret.
Scheduled builds for dynamic data
Rebuild your site automatically on a schedule β useful if your site displays data that changes over time (GitHub star counts, RSS feeds, external APIs):
on:
push:
branches: [main]
schedule:
- cron: '0 6 * * *' # Daily at 6am UTC
With a daily build, a script that fetches fresh data and writes to _data/ can run as part of the build, keeping your static siteβs data current without manual intervention.
Secrets and environment variables
Never commit API keys or tokens to your repository. Store sensitive values as GitHub Secrets and reference them in your workflow:
- name: Build with API data
run: bundle exec jekyll build
env:
JEKYLL_ENV: production
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Auto-provided
MY_API_KEY: ${{ secrets.MY_API_KEY }} # Add in repo Settings β Secrets
In your Jekyll code, access environment variables with Rubyβs ENV:
# In a Jekyll plugin or _plugins/ file
api_key = ENV['MY_API_KEY']
A complete production workflow
Here is a full workflow combining build validation, gem caching, and deployment:
name: Jekyll CI/CD
on:
push:
branches: [main]
pull_request:
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
- name: Configure Pages
id: pages
uses: actions/configure-pages@v5
- name: Build Jekyll
run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}"
env:
JEKYLL_ENV: production
- name: Validate HTML
run: |
bundle exec htmlproofer ./_site \
--disable-external \
--checks Links,Images \
--ignore-files "./_site/404.html"
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: _site/
deploy:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Debugging failed builds
When a build fails, the Actions tab in your GitHub repository shows the full log for each step. Common failure causes:
Could not find gem β a gem in your Gemfile was not found. Check that Gemfile.lock is committed, not in .gitignore.
Jekyll build error β look for the actual error message after βError:β in the build step output. Common issues: Liquid syntax errors, YAML front matter problems, or references to files that do not exist.
HTMLProofer failures β links that resolve locally but not in CI (e.g. http://localhost:4000). Add them to --ignore-urls.
Permission errors on deploy β make sure permissions: pages: write and id-token: write are set in the workflow and that GitHub Pages is configured to use GitHub Actions as the source.
GitHub Actions transforms Jekyll from a local build tool into a proper CI/CD pipeline. Once you have it set up, deploys happen automatically, your build quality is continuously validated, and you are free to use any plugin your site needs. Browse Jekyll themes on JekyllHub to find themes that include ready-to-use GitHub Actions workflow files.
Matrix builds for multiple Ruby versions
If you distribute a Jekyll theme or plugin, testing against multiple Ruby versions ensures compatibility across different user environments:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
ruby: ['3.1', '3.2', '3.3']
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
- name: Build site
run: bundle exec jekyll build
env:
JEKYLL_ENV: production
- name: Run tests
run: bundle exec rake test
This runs the entire job three times in parallel, once per Ruby version. GitHub Actions shows a combined status β any failure in any matrix cell marks the overall check as failed.
Branch protection and required status checks
Once your Actions workflow is working, use it as a gate on merging pull requests. In your repository Settings β Branches β Add rule:
- Branch name pattern:
main - Require status checks to pass before merging: check your workflowβs job name (e.g.
build) - Require branches to be up to date before merging: enabled
With this configuration, a pull request cannot be merged if the Jekyll build fails. This catches Liquid syntax errors, broken links, and invalid HTML before they reach production.
Rollback strategy
Unlike server deployments, rolling back a Jekyll site is simple β it is just redeploying an earlier build. The easiest approach:
GitHub Pages rollback: Revert the commit on your main branch with git revert and push. The Actions workflow triggers, rebuilds the reverted state, and deploys it.
Preserve build artifacts: Configure your workflow to retain _site/ artifacts for the last N builds:
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: _site/
retention-days: 14 # Keep artifacts for 14 days
If a deploy turns out bad, you can download the artifact from a previous successful run and redeploy it manually.
Build time optimisation
As sites grow, Jekyll builds slow down. A 200-post site can take 30β60 seconds on CI. Several strategies help:
Incremental builds skip regenerating pages that have not changed. Add --incremental to your build command:
bundle exec jekyll build --incremental
Note: incremental builds can occasionally miss changes to layouts or includes. Use them for content-heavy deployments where post-list regeneration is the bottleneck, but always do a full build before deploying to production.
Limit posts in development. In _config.yml:
limit_posts: 20 # Only build 20 posts locally
This makes jekyll serve fast for development without affecting the production build (which uses a separate config that overrides limit_posts: 0).
Profile your build. Jekyllβs --profile flag prints a table showing which files take the most time to render:
bundle exec jekyll build --profile
If a particular layout or include is slow, you can target it for optimisation β often by reducing Liquid filter chains or by caching computed values with assign.
Common Actions patterns for Jekyll
A few patterns that come up frequently:
Only run HTMLProofer on full builds, not PRs β saves time on pull request checks:
- name: Validate HTML
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: bundle exec htmlproofer ./_site ...
Send a Slack notification on failed production deploys:
- name: Notify on failure
if: failure() && github.ref == 'refs/heads/main'
uses: slackapi/slack-github-action@v1
with:
payload: '{"text": "Jekyll build failed on main: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Regenerate search index after build:
- name: Index content with Algolia
if: github.ref == 'refs/heads/main'
run: bundle exec jekyll algolia
env:
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
These patterns compose well β start with the minimal workflow and add capabilities one at a time as your site grows and your deployment requirements become clearer.