Generate Report →

Automating Hugo Deployments to Cloudflare Pages With GitHub Actions

A production Hugo pipeline: pinned versions, cached resources, HTML and link validation gates, preview URLs per PR, and Wrangler deploys on every merge to main.

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 sitemap

The 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: true

Internal-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 profileDominant costCache helps?
Small site, no image processingHugo startup and I/OBarely — already fast
Image derivatives in WebPEncodingSubstantially
Image derivatives in AVIFEncoding, by a wide marginDecisively
Large multilingual siteTemplate executionNot 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.1

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

Run this article on your site

Write a production GitHub Actions workflow that builds my Hugo site and deploys it to Cloudflare Pages with Wrangler. Pin an explicit Hugo extended version, cache both resources/_gen and the Hugo module cache, run htmltest as a blocking link-validation gate before deploy, deploy pull requests to preview branches and only main to production, and post the resulting preview URL back as a PR comment. Store the Cloudflare API token and account ID as repository secrets and never inline them.

Paste into Claude Code, ChatGPT, Cursor or Gemini. It executes the steps above against your own site.

Frequently Asked Questions

Why use GitHub Actions when Cloudflare Pages can build from Git directly?

The built-in integration is fine for a plain site, but it gives you no control over the steps before deployment. With Actions you can pin the exact Hugo version, cache processed image resources, run link checkers and HTML validators as blocking gates, and refuse to deploy a build that fails them. The native integration will happily publish a broken site.

How do I pin the Hugo version so builds stay reproducible?

Use peaceiris/actions-hugo with an explicit hugo-version such as '0.128.0' and extended: true, and never use 'latest'. An unpinned version means a Hugo release can break your templates on an unrelated commit, and you will lose an afternoon before you realise the input did not change.

What is the difference between a Cloudflare Pages preview and production deploy?

Wrangler assigns the deployment to a branch. Deploying with --branch=main publishes to your production domain; any other branch name produces an isolated preview URL on the pages.dev subdomain with its own build. Post that preview URL as a PR comment and reviewers can see the rendered change before merge.

How do I stop CI from rebuilding every image on every run?

Cache the resources/_gen directory with actions/cache, keyed on a hash of your assets folder. Hugo stores processed image derivatives there keyed by source hash and processing spec, so a warm cache turns a slow AVIF encode into a no-op and leaves template execution as the dominant remaining cost.

Should the sitemap ping and cache purge happen in the workflow?

Yes, as post-deploy steps conditioned on the production branch only. A purge on a preview deploy is wasted, and pinging search engines with a preview URL is actively harmful. Gate both behind an if condition checking github.ref equals refs/heads/main.

Continue the track — Static Architecture & Performance