Moving My Blog from Hugo to Jekyll with Chirpy and GitHub Pages
Why I moved this blog off Hugo and onto Jekyll with the Chirpy theme, how I brought the posts across without breaking any links, and the GitHub Actions pipeline that builds, tests, and deploys the site for me.
When I first set this blog up I went with Hugo and the Blowfish theme. It looked great and did pretty much everything I wanted for a while. The annoyances were small ones, but they added up. I had to keep the Hugo binary pinned to a version my theme was happy with, the build really only lived on my laptop, and getting a new post live always felt a bit more hands on than I wanted.
So I did what I usually do with my own projects. I pulled the whole thing apart and built it again. This time on Jekyll with the Chirpy theme, building and deploying itself every time I push, all through GitHub Actions. This is the story of that move. Why I switched, how I brought the posts across without breaking any links, and the automation that runs it all now.
The Goal
- Move from Hugo to Jekyll without losing any posts, and just as importantly without breaking any of my existing links.
- Let GitHub Pages build the site for me instead of running a binary on my laptop.
- Get a proper pipeline that builds, tests, then deploys whenever I merge to main.
- Keep writing and previewing locally quick and painless.
Part 1 - Why Jekyll
The simple answer is that GitHub Pages and Jekyll just go together. Pages has built in support for Jekyll, so hosting the site and building it stop being two separate jobs I have to think about. On top of that, the Chirpy theme already comes with most of the bits I would otherwise have to bolt on myself. A clean layout that stays out of the way, category and tag pages, a table of contents, dark mode, and comments. That is a lot less for me to look after, and a lot more time left over for actually writing.
Chirpy also ships as a gem rather than a big folder of theme files I have to copy into my repo, which keeps things tidy. The whole theme is one line in the Gemfile.
1
2
3
4
5
source "https://rubygems.org"
gem "jekyll-theme-chirpy", "~> 7.6"
gem "html-proofer", "~> 5.0", group: :test
Updating the theme is now just a version bump rather than a painful merge against a hundred files I did not write.
Part 2 - Local Setup
Jekyll runs on Ruby, so writing locally means Ruby and Bundler. Day to day there are really only two commands I reach for.
1
2
bundle install # first run, pulls in the theme and deps
bundle exec jekyll serve # live preview at http://127.0.0.1:4000
To save myself the usual headache of getting the right Ruby version onto every machine, the repo carries a dev container so the setup is the same everywhere I open it.
1
2
3
4
5
{
"name": "Jekyll",
"image": "mcr.microsoft.com/devcontainers/jekyll:2-bullseye",
"postCreateCommand": "bash .devcontainer/post-create.sh"
}
There is a small post create script that sets up a couple of my shell plugins so a fresh container feels like home. The whole idea is that starting a new post never begins with a fight against the tooling.
Part 3 - Moving the Posts Across
Bringing the posts over was the calm part, which is exactly what you want from a migration. Hugo keeps its posts in a content folder. Jekyll keeps them in a _posts folder and is fussy about the file name, which has to start with the date. So every post became a rename.
1
content/posts/dns-as-code.md -> _posts/2026-05-23-dns-as-code-cloudflare-terraform.md
The front matter only needed a light tidy rather than a full rewrite. Chirpy wants categories and tags, so I added those. It happily ignores the leftover publishDate and draft fields from Hugo, so I left them alone rather than editing every single file.
1
2
3
4
5
6
7
8
---
title: "DNS as Code with Cloudflare, Terraform, and GitHub Actions"
description: "..."
date: 2026-05-23T00:00:00+11:00
tags: ["cloudflare", "terraform", "github-actions", "dns", "ci-cd", "vault"]
categories: [Automation, IaC]
comments: true
---
Markdown is still Markdown, so the actual writing came across untouched. The only real work was swapping a few Hugo shortcodes for plain Markdown or the Chirpy way of doing the same thing.
Part 4 - Not Breaking the URLs
This is the bit I cared about the most. Hugo served all my posts under /posts/, and those links are already out in the wild in feeds, on other sites, and in search results. Breaking them on a move is how you quietly throw away every bookmark and every bit of search ranking you have built up.
Jekyll does not use that link shape by default, so I set it back to match Hugo in _config.yml.
1
2
3
4
5
6
7
defaults:
- scope:
path: ""
type: posts
values:
layout: post
permalink: /posts/:title/
With that one setting, a post that lived at /posts/dns-as-code-cloudflare-terraform/ on Hugo now lives at the exact same address on Jekyll. My custom domain came across through the CNAME file, so from the outside nobody could tell anything had changed. That was the whole aim. A reader should never have to know the engine underneath got swapped out.
Part 5 - The Build and Deploy Pipeline
This is the upgrade I was really after. Rather than building the site on my laptop and pushing the result, one GitHub Actions workflow now builds it, checks the output, and deploys it to GitHub Pages for me. It runs on every push to main and on every pull request, so I get to see a build pass before anything goes live.
The build job sets up Ruby with a cached bundle and builds the site for production.
1
2
3
4
5
6
7
8
9
10
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"
bundler-cache: true
- name: Build site
run: bundle exec jekyll build -d "_site$"
env:
JEKYLL_ENV: production
The step I would not want to lose is the test. Before anything ships, html-proofer walks the built site and fails the run if it finds a broken internal link, a missing image, or a plain http link that should be https.
1
2
3
4
5
6
7
- name: Test site
run: |
bundle exec htmlproofer "_site" \
--disable-external \
--enforce-https \
--ignore-urls "/^http:\/\/127.0.0.1/,/^http:\/\/localhost/" \
--ignore-files "/_site\/feed.xml"
Only once the build and the tests pass does the deploy job push the site live, and only on a real push to main, never on a pull request.
1
2
3
4
5
6
deploy:
if: github.event_name != 'pull_request'
needs: build
steps:
- name: Deploy to GitHub Pages
uses: actions/deploy-pages@v5
A concurrency setting keeps deploys one at a time so two pushes can never trip over each other on the way out. The upshot is that publishing is now just merging to main, and if I fumble a link I get a failed check instead of a broken page.
Part 6 - The Small Touches
A few extra bits make the day to day nicer.
- Last modified dates happen on their own. A little Jekyll plugin reads each post’s Git history and adds an updated date once a file has more than one commit, so edited posts show the right date without me touching anything.
- Category and tag pages build themselves. The archives plugin turns the categories and tags in my front matter into their own pages, with no manual lists to keep up to date.
- Comments with nothing to run. Comments are handled by giscus and live in a separate GitHub Discussions repo, so there is no database for me to babysit.
- Updates that mostly look after themselves. Renovate opens a pull request whenever the theme or a gem has an update, and since every pull request runs the full build and test, I can merge it knowing the site still works.
The Day-to-Day
The whole thing is about as boring as I could make it now, which is the nicest thing I can say about a setup.
- Writing a post? Drop a Markdown file in
_posts, preview it locally, open a pull request. - Merging it? Actions builds the site, html-proofer checks every link, and Pages puts it live a couple of minutes later.
- Theme or gem update? Renovate opens the pull request, the pipeline proves it still builds, and I click merge.
Hugo got me writing and I am glad I started there. But moving over to Jekyll on Pages turned the blog from something I build into something that builds itself. The only part left that still needs me is the writing, and that is exactly where I want my attention to be.