A Hugo site can be deployed by dragging a folder into a dashboard. That works exactly until the day someone publishes a post with a broken shortcode, or a stale image cache ships the wrong hero, or two people deploy from different machines running different Hugo versions and produce different HTML from identical source.
A real pipeline solves those by making the build reproducible and the deploy conditional. Same Hugo version every time, same cached derivatives, and a set of gates that refuse to publish output that fails validation.
What follows is a working GitHub Actions workflow for Hugo on Cloudflare Pages, plus the specific steps most tutorials leave out: resource caching, link validation as a blocking gate, per-PR preview URLs, and post-deploy tasks scoped to production only.
The pipeline shape
feature branch push main branch push
│ │
▼ ▼
┌─────────────────────────────────────────────────────┐
│ 1. checkout (fetch-depth: 0 — Lastmod needs history)│
│ 2. restore cache: resources/_gen + /tmp/hugo_cache │
│ 3. setup hugo (pinned, extended) │
│ 4. hugo --minify --gc │
│ 5. htmltest ── fails? ─► STOP, nothing deploys │
└─────────────────────────────────────────────────────┘
│ │
▼ ▼
wrangler --branch=pr-42 wrangler --branch=main
│ │
preview URL marketlens.work
posted to PR │
▼
purge cache + ping sitemapThe single most important property of this shape is that validation sits before deployment, not after. A broken-link check that runs on the live site tells you that you already shipped a problem.
The workflow file
name: Deploy Hugo to Cloudflare Pages
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
deployments: write
pull-requests: write
concurrency:
group: pages-${{ github.ref }}
cancel-in-progress: true
env:
HUGO_VERSION: "0.128.0"
jobs:
build-deploy:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Cache Hugo resources
uses: actions/cache@v4
with:
path: |
resources/_gen
/tmp/hugo_cache
key: hugo-${{ env.HUGO_VERSION }}-${{ hashFiles('assets/**', 'config/**') }}
restore-keys: |
hugo-${{ env.HUGO_VERSION }}-
- uses: peaceiris/actions-hugo@v3
with:
hugo-version: ${{ env.HUGO_VERSION }}
extended: true
- name: Build
run: hugo --minify --gc --cleanDestinationDir --cacheDir /tmp/hugo_cache
- name: Validate HTML and links
uses: wjdp/htmltest-action@master
with:
config: .htmltest.yml
- name: Deploy
uses: cloudflare/wrangler-action@v3
id: deploy
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: >-
pages deploy public
--project-name=marketlens
--branch=${{ github.head_ref || github.ref_name }}
- name: Comment preview URL
if: github.event_name == 'pull_request'
uses: thollander/actions-comment-pull-request@v2
with:
message: "Preview: ${{ steps.deploy.outputs.deployment-url }}"
- name: Purge cache and ping sitemap
if: github.ref == 'refs/heads/main'
run: |
curl -sX POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CF_ZONE_ID }}/purge_cache" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
curl -s "https://www.bing.com/ping?sitemap=https://marketlens.work/sitemap.xml"One naming note before the walkthrough, because it wastes a lot of people’s evenings: the deploy command is wrangler pages deploy. It was wrangler pages publish before Wrangler 3, and a great deal of still-ranking tutorial content documents the old form. If you copied a snippet that fails on an unrecognised subcommand, that is why.
Several lines are load-bearing and easy to omit by accident.
fetch-depth: 0 gives the runner full Git history. Hugo’s .Lastmod reads from git log when enableGitInfo is on, and a shallow clone silently sets every page’s modification date to the build date — which destroys the freshness signal that recency-sensitive retrieval pipelines depend on.
concurrency with cancel-in-progress prevents two pushes to the same branch from racing to deploy, where the older build can finish last and overwrite the newer one.
--gc runs garbage collection on the resource cache so orphaned derivatives from deleted images do not accumulate indefinitely. --cleanDestinationDir removes stale files from public/ so a renamed page does not leave its old HTML behind forever.
The github.head_ref || github.ref_name expression is the whole preview mechanism: on a pull request it resolves to the source branch and produces an isolated preview URL, on a push to main it resolves to main and publishes to production.
Gates worth blocking on
A deploy step that always succeeds is a deploy step that ships broken pages. Three checks catch nearly everything, and all are fast enough to run on every commit.
Link validation. htmltest crawls the generated public/ directory and reports broken internal links, missing images, and malformed anchors. Configure it to check internal links strictly and external links on a schedule rather than every run, since third-party outages will otherwise block unrelated deploys:
DirectoryPath: "public"
CheckExternal: false
CheckInternal: true
CheckImages: true
IgnoreDirectoryMissingTrailingSlash: trueInternal-link integrity matters beyond user experience. Hub-and-spoke internal linking only distributes authority if the links resolve, and a broken contextual link is a dead edge in your site graph.
Front matter validation. If your templates depend on required fields, a small script that walks content/ and fails on any file missing them prevents a whole class of silently degraded pages. This is where schema-driven sites catch a missing description or date before it reaches production rather than three weeks later in a crawl report.
Build warnings as errors. Hugo will happily build with unresolved shortcodes and missing partials. Grepping the build log for WARN and failing the job turns those into blocking errors.
Why preview deployments change the review process
A pull request that changes a template shows up as a diff in Go template syntax. Almost nobody can review that accurately by reading it. A preview URL posted to the PR turns it into a page a human can look at.
Cloudflare Pages preview deployments are full independent builds on their own *.pages.dev subdomain, with their own cache. Two operational notes:
Add X-Robots-Tag: noindex to preview environments so they never get indexed and compete with production. In Cloudflare Pages this goes in the preview environment’s headers configuration, not your committed _headers file — otherwise you noindex production too.
Preview builds count toward your Pages build minutes. On a busy repository, restricting the trigger to pull requests rather than every branch push keeps that in check.
Where the build time actually goes
Rather than quote build times from someone else’s hardware, measure your own — CI runners, image counts, and template complexity vary too much for a benchmark table to mean anything. What is stable is which stage dominates, and it is almost always one of these:
| Site profile | Dominant cost | Cache helps? |
|---|---|---|
| Small site, no image processing | Hugo startup and I/O | Barely — already fast |
| Image derivatives in WebP | Encoding | Substantially |
| Image derivatives in AVIF | Encoding, by a wide margin | Decisively |
| Large multilingual site | Template execution | Not at all |
AVIF encoding is dramatically slower than WebP, and without a cache every CI run pays that cost in full for every image — which is the entire argument for caching resources/_gen. The pipeline details are in build-time image optimization with WebP and AVIF.
Note the last row: caching does nothing for template execution, because that work is redone on every build by definition. If a warm-cache build is still slow on a mid-sized site, the usual culprit is a partial doing an O(n²) where over .Site.Pages inside a range — related-posts logic is the classic offender. Run hugo --templateMetrics --templateMetricsHints to find it, and trust that output over any table.
Secrets, tokens and the supply chain
Two secrets are required: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. Create the token with the “Cloudflare Pages — Edit” template scoped to the single project, not a global key. If you add the cache-purge step, that needs “Zone — Cache Purge” on the specific zone.
Then pin your actions. Referencing @v4 follows a mutable tag; if that tag is ever repointed at a compromised commit, the malicious code executes with access to your deploy token. Pinning to a full commit SHA removes that path:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1This is not theoretical caution. On a static site, the build pipeline is the primary remaining attack surface precisely because everything else was removed — the full threat model is in the zero-database security model for static architecture.
Getting there from here
If you are currently deploying by dashboard or by hand, do it in three commits. First, get the build and deploy working on main with a pinned Hugo version — that alone ends version drift. Second, add resource caching, which is where the build time becomes tolerable enough that nobody looks for workarounds. Third, add htmltest and the pull-request preview, which is where the pipeline starts preventing problems rather than just automating publication.
Total setup is under an hour, and the resulting workflow will run unchanged for years. If you want the current state of your deployed output checked — broken links, stale dateModified values, missing headers — that inventory is part of the Standard MarketLens audit.
MarketLens