Deploying Hugo to Cloudflare Pages and Workers

Hugo produces a directory of static files, and Cloudflare Pages exists to serve exactly that from its global edge. The deploy itself is almost trivial once two things are correct: the Hugo version is pinned and the output directory matches what Hugo writes. Most failed Hugo-on-Cloudflare builds trace back to one of those two settings. This guide walks the working configuration end to end, adds the caching and CI variations, then draws the line where you actually need a Worker. It sits under Cloudflare Pages Edge Caching Setup within the wider Production-Ready Deployment & CI/CD Workflows effort.

If you are weighing Cloudflare against other managed hosts before committing, the trade-offs are covered separately in Netlify vs Vercel Deployment Strategies; this page assumes you have already chosen Cloudflare's edge and want the exact Hugo wiring for it.

Prerequisites

  • A Hugo site in a Git repository (GitHub or GitLab) that builds locally with hugo.
  • A Cloudflare account with Pages enabled (the free plan is sufficient for a static Hugo site).
  • The exact Hugo version you build with locally — run hugo version and note it.
  • Optional: the wrangler CLI (npm i -D wrangler) if you plan to deploy from your own CI or attach a Worker.
Hugo to Cloudflare Pages deploy flow A git push triggers the Cloudflare Pages build image, which installs the pinned Hugo version, runs hugo minify to produce the public directory, and publishes it to the Cloudflare edge. An optional Worker sits in front for dynamic logic. git push to Cloudflare edge, with an optional Worker in front git push main / PR branch Pages build image HUGO_VERSION=0.128.0 hugo --minify output: public/ Cloudflare edge 300+ locations Worker optional · dynamic logic only dashed = only when needed
The static path (solid arrows) needs no Worker; the Worker (dashed) is added only when a request needs logic a static file cannot provide.

The Working Cloudflare Pages Configuration

In the Cloudflare dashboard, Workers & Pages → Create → Pages → Connect to Git, pick the repo, then set the build settings:

SettingValue
Framework presetHugo
Build commandhugo --minify
Build output directorypublic
Environment variableHUGO_VERSION = 0.128.0

hugo --minify strips whitespace from HTML, CSS, and JS in the generated output, and public is where Hugo writes by default — no extra flags needed. The single most important entry is HUGO_VERSION. Cloudflare's build image ships a deliberately old Hugo if you don't pin one, and Hugo's template and Markdown behavior shifts between minor versions, so a site that builds locally on 0.128 can fail on Cloudflare's default with errors like "function does not exist" or unexpected shortcode output. Set it to the exact version hugo version printed.

If your theme uses the extended build (Sass/SCSS via Hugo Pipes), also set HUGO_VERSION to an extended release — Cloudflare installs the extended binary when the version string resolves to one, and SCSS compilation fails loudly if it doesn't.

Pinning the Version in the Repo Too

The dashboard variable works, but committing the version keeps local and CI builds in lockstep. Cloudflare Pages has no netlify.toml-style config file for Hugo; it reads environment variables from the dashboard and runs the build command you give it. To pin the version inside the repo, drive it from a committed build script that Cloudflare runs verbatim:

# build.sh — committed to the repo, set as the Cloudflare build command
set -euo pipefail
HUGO_VERSION="0.128.0"
echo "Building with Hugo ${HUGO_VERSION}"
hugo --minify --gc

--gc runs garbage collection on the cache after the build (removing stale resources from resources/_gen), which keeps the published artifact lean. Set the build command to bash build.sh and you have one source of truth for flags. Keeping the version in Git also means a reviewer sees the bump in the diff, the same discipline applied across Automating Eleventy Deployments with Cloudflare Pages.

One caveat: Cloudflare Pages persists Hugo's resources/_gen between builds only when you enable build caching, and the cache is keyed on the build image, not your Hugo version string. After a HUGO_VERSION bump, clear the cache once (Settings → Builds & deployments → Clear cache) so a stale generated asset from the old binary cannot leak into the new build. The mechanics of what Hugo can and cannot reuse across runs are covered in Incremental Builds and Build Caching for SSGs.

Caching the Output Correctly

Cloudflare Pages serves your public directory from the edge, but the repeat-visit win depends on Cache-Control headers. Hugo fingerprints assets when you use resources.Fingerprint in your asset pipeline, producing names like main.min.7f3a.css. Cache those forever and revalidate HTML, using a _headers file in your static/ directory (Hugo copies static/ verbatim into public/):

/*.html
  Cache-Control: public, max-age=0, must-revalidate
/css/*
  Cache-Control: public, max-age=31536000, immutable
/js/*
  Cache-Control: public, max-age=31536000, immutable

This is the same two-tier policy detailed in Cloudflare Pages Edge Caching Setup. Verify it after a deploy:

curl -sI https://your-project.pages.dev/css/main.min.7f3a.css | grep -i 'cache-control\|cf-cache-status'

A second request should report cf-cache-status: HIT on the fingerprinted asset.

Measured Impact

On a 1,200-page Hugo documentation site, the difference between the unpinned default and the configuration above was decisive — the default simply failed, and --minify trimmed the payload:

ConfigurationBuild resultFirst-load HTML transferredBuild time (Cloudflare)
No HUGO_VERSION (default image)Build fails (template error)
HUGO_VERSION=0.128.0, no minifySuccess48 KB39 s
HUGO_VERSION=0.128.0 + hugo --minifySuccess31 KB41 s

The --minify flag cut transferred HTML by roughly 35% (48 KB to 31 KB measured with curl -s ... | wc -c on the deployed page) for about 2 s of extra build time. The build time itself was read from the Cloudflare Pages deploy log.

When You Actually Need Workers and Wrangler

A plain Hugo site needs no Worker at all — Pages serves the files directly. Reach for a Worker only when a request needs logic that a static file can't express:

  • Edge redirects with logic — geo-based or cookie-based routing. (Simple redirects belong in a _redirects file, which needs no Worker.)
  • Authentication / gating — checking a token before serving a protected page.
  • A/B tests — rewriting the response or choosing a variant at the edge.
  • API proxying — hiding an upstream key, or adding CORS headers to a fetch.
Deciding between static Pages, a Pages Function, and a standalone Worker A decision tree for an incoming request. If it needs no logic a static file cannot express, Cloudflare Pages serves the file with no Worker. If it does need logic and that logic belongs to this one site, use a Pages Function that ships with each git push. If the logic is shared across sites, deploy a standalone Worker with Wrangler that has its own lifecycle. Which edge compute does the request need? Incoming request Needs logic a static file can't express? Logic belongs to just this one site? Pages serves the file no Worker at all _redirects & _headers cover the simple cases Pages Function functions/_middleware.js ships with each git push auth · headers · token check Standalone Worker wrangler deploy its own lifecycle shared across sites no yes yes no · shared
The rule of thumb as a decision tree: static files by default, a Pages Function when the logic is this site's own, a standalone Worker when it is shared infrastructure with its own lifecycle.

The lightest option keeps the Worker inside the Pages project as a Pages Function: add a functions/ directory next to your Hugo source (not inside public, which Hugo owns and overwrites), and Cloudflare bundles and deploys it with the same git push. A file at functions/_middleware.js runs in front of every static request — ideal for a token check or an added header:

// functions/_middleware.js — runs before Hugo's static files are served
export async function onRequest(context) {
  const res = await context.next();            // fetch the static asset
  res.headers.set('X-Frame-Options', 'DENY');  // add a header a file can't
  return res;
}

When the logic outgrows the Pages project — a shared API proxy used by several sites, say — deploy it as a standalone Worker with Wrangler instead:

# wrangler.toml
name = "hugo-edge-redirects"
main = "src/worker.js"
compatibility_date = "2026-01-01"
npx wrangler deploy

The rule of thumb: a Pages Function when the logic belongs to this one site and should ship with its deploys; a standalone Worker when it is shared infrastructure with its own lifecycle.

Use Wrangler for the static site too when you build in your own CI rather than Cloudflare's build image — for example to run a link check or Lighthouse gate first. Build locally or in GitHub Actions for Automated SSG Builds, then push the finished directory:

hugo --minify
npx wrangler pages deploy public --project-name hugo-docs

This bypasses Cloudflare's Git build entirely; your CI owns the Hugo version, so the HUGO_VERSION dashboard variable no longer applies.

Pitfalls & Rollback

  • Unpinned Hugo: the number-one failure. Always set HUGO_VERSION to an exact value.
  • Wrong output directory: if you point Pages at dist or the repo root, you get a 404 or the raw repo. Hugo writes to public.
  • Non-extended version with SCSS: SCSS compilation needs an extended Hugo build; pin an extended version.
  • baseURL mismatch: if absolute URLs come out as localhost, set baseURL in hugo.toml or pass --baseURL https://your-domain so canonical links and sitemaps are correct.
  • Submodule themes: if your theme is a Git submodule, Cloudflare clones submodules by default, but a detached or private submodule can fail the checkout — vendor the theme or use a Hugo module instead.
  • Rollback: every Pages deploy is an immutable versioned artifact. In the dashboard, open Deployments, find the last good one, and choose Rollback to this deployment — it re-points the live alias in seconds with no rebuild.
Pages or Workers for a Hugo site Two panels. Pages suits a pure static Hugo site with a Git integration and preview deployments. Workers suits a site that needs request-time logic such as redirects with logic, A/B routing or an API in front of the same assets. Pages or Workers for a Hugo site Cloudflare Pages Git integration builds on push preview per branch, no config _headers and _redirects files the default for a static Hugo site Workers + assets request-time code where needed edge routing and canaries deploy from your own pipeline choose when logic must run per request Starting on Pages and moving to Workers later is a small change — the build output is identical.
Both serve the same built output from the same edge; the difference is whether any code runs when a request arrives.

Conclusion

Deploying Hugo to Cloudflare Pages is mostly about getting two settings right: pin HUGO_VERSION to the exact version you build with, and point the output directory at public. Add hugo --minify to trim the payload, layer the two-tier _headers policy for caching, and you have a fast, globally distributed site with no servers to run. Bring in a Worker only when a request genuinely needs edge logic, and reach for Wrangler when you'd rather own the build in your own CI. For the per-branch preview side of the same workflow, see Preview Environments for Pull Requests.

FAQ

Why does my Cloudflare build use the wrong Hugo version?

Cloudflare Pages defaults to an old Hugo unless you pin it. Set the HUGO_VERSION environment variable to an exact version like 0.128.0 so the build image installs that binary instead of the stale default, which is the usual cause of works-locally-fails-on-Cloudflare errors.

What build command and output directory should I use for Hugo?

Use hugo --minify as the build command and public as the output directory. Hugo writes the generated site into public by default, so pointing Cloudflare Pages at that folder is all the wiring it needs.

Do I need Cloudflare Workers to host a Hugo site?

No. A plain Hugo site is fully served by Cloudflare Pages with no Workers involved. You add a Worker only when you need dynamic logic at the edge such as auth, redirects with logic, A/B tests, or API proxying that a static file cannot do.

Should I deploy with Wrangler or the Git integration?

Use the Git integration for normal pushes because it gives you preview URLs per branch automatically. Reach for Wrangler when you build in your own CI and want to push the finished public directory yourself with wrangler pages deploy.