↓ Skip to main content
  1. Blog/

My Hugo Publishing Process

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:

PieceJob
HugoTurns a folder of Markdown files into a static website. It’s a single binary, and builds this whole site in under two seconds.
GitHubHolds the source, and runs a GitHub Actions workflow on every push to main that builds the site and deploys it.
CloudflareServes 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 image

I start one from a template with:

hugo new content posts/2026/my-new-post/index.md

That 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 drafts

If 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: false

followed by:

git add -A && git commit -m "Publish: My new post" && git push

About 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.

Related

Moving from Map My Run to Strava

I have been running a little while now and am always in the habit of taking my phone with me to get some monitoring in on my progress with the aid of GPS and so on. I have been using a really nice app called mapmyrun. It has given me months of good service and…

Titlecase: A Great Find

·2 mins
As a rather inexperienced writer, it is the little things I struggle with, as well as the big things you might add. I find that one of the most annoying is getting title case correct. There are a lot of style guides about which may or may not help; I find some of them cryptic…