This blog has had a few publishing processes over the years. In 2014 I wrote up my Pelican publishing process, then moved the build into a Docker container, and in 2020 revisited it again. Every version had the same weakness: publishing depended on one particular machine being set up just right. Whenever I didn’t have that machine to hand, the blog stalled, which goes a long way to explaining the long gaps between posts.
The new setup fixes that. The site is now built with Hugo, the source lives in a Git repository, and nothing about publishing depends on any machine I own. Here’s how it fits together.
The moving parts#
flowchart LR A["Any machine
edit Markdown
hugo server"] -- "git push" --> B["GitHub
repo + Actions"] B -- "hugo --minify" --> C["Built site
./public"] C -- "wrangler deploy" --> D["Cloudflare
serves the site"]
There are three pieces, and each does one job:
| Piece | Job |
|---|---|
| Hugo | Turns a folder of Markdown files into a static website. It’s a single binary, and builds this whole site in under two seconds. |
| GitHub | Holds the source, and runs a GitHub Actions workflow on every push to main that builds the site and deploys it. |
| Cloudflare | Serves the built files from its edge network, as static assets on a Worker. Static asset requests are free and unmetered. |
The theme is Blowfish, added to the repository as a Git submodule.
Writing a post#
Every post is a folder (Hugo calls it a page bundle) with an index.md and any
images it uses sitting right beside it:
content/posts/2026/hugo-publishing-process/
├── index.md
└── feature.jpg # optional: Blowfish uses it as the post's hero imageI start one from a template with:
hugo new content posts/2026/my-new-post/index.mdThat creates the file with front matter already filled in, including draft: true:
---
title: "My New Post"
date: 2026-11-05T12:00:00Z
draft: true
summary: ""
tags: []
categories: ["blog"]
---Then I run a local preview, which rebuilds and reloads the browser every time I save:
hugo server -D # -D includes draftsIf I’m editing on a headless home server rather than my laptop, two more flags let me browse to the preview from another machine on the network:
hugo server -D --bind 0.0.0.0 --baseURL http://my-server:1313/Drafts make Git a safe place for unfinished work#
Because the live build skips anything marked draft: true, I can commit and push a
half-written post and nothing appears on the site. The draft is backed up on GitHub,
and I can pick it up again on any machine. Publishing is just changing one line:
draft: falsefollowed by:
git add -A && git commit -m "Publish: My new post" && git pushAbout a minute later, it’s live.
The workflow file#
This is the whole deployment pipeline, in .github/workflows/deploy.yml:
name: Build and deploy
on:
push:
branches: [main]
workflow_dispatch: # adds a manual "Run workflow" button
concurrency:
group: deploy
cancel-in-progress: true # a newer push supersedes an older build
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
submodules: recursive # pulls in the theme
fetch-depth: 0
- uses: peaceiris/actions-hugo@v3
with:
hugo-version: "0.167.0"
extended: true
- name: Build
run: hugo --minify
- uses: actions/setup-node@v5
with:
node-version: 22
- name: Deploy to Cloudflare
run: npx --yes wrangler@4 deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}Only pushes to main deploy, so a branch is a safe place to try something bigger. I
trialled two different themes on branches before settling on Blowfish, and the live
site never knew.
The deploy step reads a small wrangler.toml that tells Cloudflare where the built
files are:
name = "chrisramsay"
compatibility_date = "2026-11-01"
[assets]
directory = "./public"
not_found_handling = "404-page"A note on the deploy step#
I started out with Cloudflare’s own wrangler-action for the deploy step. It
installed an old version of Wrangler by default and hid Wrangler’s error output, so
when the first deploy failed, the log just said the command exited with code 1.
Calling npx wrangler@4 directly is simpler, pins the major version, and prints any
error straight into the log.
Secrets#
The workflow needs two values from Cloudflare, stored as GitHub Actions secrets:
CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. The token is created from
Cloudflare’s “Edit Cloudflare Workers” template and scoped to just my account and this
one domain, so a leaked token couldn’t touch anything else I run there.
Pinning versions#
Every external piece is pinned to an exact version: Hugo in the workflow, the theme as a submodule at a release tag, and Wrangler to its major version. Two reasons:
- Reproducible builds. A post written today should build identically in six months. An unpinned “latest” means a theme or Hugo update can quietly change or break the site between two pushes that touched nothing but text.
- Supply-chain caution. When I upgrade, I pick releases that are at least a couple of weeks old. A newly published release is the most likely place for a malicious or broken change to slip through before anyone notices.
Hugo needs matching on every machine I write on, too. Each Blowfish release declares the Hugo versions it’s been tested with, so I upgrade the theme first and then move Hugo to a version inside its range, in the workflow and on my machines together.
Keeping the old URLs#
Moving from Pelican was a chance to change the URL structure, and I deliberately
didn’t. Hugo’s permalinks setting reproduces the old shapes exactly:
[permalinks]
[permalinks.page]
posts = "/posts/:year/:month/:slug/"
pages = "/pages/:slug/"
[permalinks.term]
tags = "/tag/:slug/"
categories = "/category/:slug/"So a link someone saved to one of my 2014 cloud detection posts still works.
The migration, briefly#
The old posts were written in reStructuredText, and Hugo wants Markdown. A one-off Python script converted all of them with pandoc, translating the Pelican metadata into Hugo front matter, rewriting internal links, turning maths into a form Hugo renders at build time, and pulling the Flickr-hosted images down into each post’s folder. The posts from my old astronomy site went through the same script and were folded in here, with the one duplicated post left out.
What I like about it#
- Nothing to look after. There’s no server and no build machine of my own. If my laptop dies tomorrow, I can clone the repository anywhere and carry on.
- It’s fast. Push, and the post is live in about a minute.
- It’s free. GitHub Actions and Cloudflare’s static hosting both cost nothing at this scale.
- Everything is in Git. Posts, configuration, the workflow and the migration scripts all live in one repository, with full history.
After three Pelican setups that each slowly rotted, the real test is whether this one
is still working, untouched, in a few years. It’s off to a good start: this post went
from my editor to the live site with nothing more than a git push.