How to Set Up GitHub Actions for Hugo Deployments
Hugo deploys cleanly from GitHub Actions: build with the extended edition, then publish the public/ directory to GitHub Pages — or any host. Two details trip people up the first time. You need hugo-extended for SCSS and WebP, and a Hugo Pages pipeline is genuinely two jobs — a build that uploads an artifact and a separate deploy that publishes it — not one. Get those right and every push to main ships the site. This is the Hugo-specific recipe under GitHub Actions for Automated SSG Builds, part of Production-Ready Deployment & CI/CD Workflows.
All build times below come from the GitHub Actions job summary on a real Hugo documentation site (~600 pages, ~600 processed images, a SCSS theme), measured with the extended edition 0.162.0 on ubuntu-latest.
Prerequisites
- A Hugo site in a GitHub repository, building locally with
hugo --minify. - GitHub Pages enabled for the repo with the source set to GitHub Actions (Settings → Pages → Build and deployment → Source).
- If your theme is a git submodule, the submodule URL committed in
.gitmodules. baseURLin your Hugo config set to the production domain you'll deploy to.
public/ as a Pages artifact; the deploy job, gated on needs: build, publishes it. Without that second job the artifact is built but never goes live.The Build-and-Deploy Workflow
Build on push to main, then publish to GitHub Pages with the official deploy-pages action. Note submodules: recursive for themes added as submodules and fetch-depth: 0 so Hugo's .GitInfo lastmod dates resolve:
name: Deploy Hugo Site
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
env:
HUGO_ENV: production
HUGO_VERSION: 0.162.0
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0 # full history so .GitInfo lastmod dates work
- uses: peaceiris/actions-hugo@v3
with:
hugo-version: ${{ env.HUGO_VERSION }}
extended: true # required for SCSS and WebP
- name: Cache Hugo resources
uses: actions/cache@v4
with:
path: |
resources/_gen
~/.cache/hugo_cache
key: ${{ runner.os }}-hugo-${{ hashFiles('content/**', 'assets/**', 'config.*') }}
restore-keys: ${{ runner.os }}-hugo-
- run: hugo --minify --gc --baseURL "${{ vars.BASE_URL }}"
- uses: actions/upload-pages-artifact@v3
with:
path: ./public
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
The build job produces the artifact; the separate deploy job (needs: build) publishes it. Without that second job the site never updates — the most common "my workflow is green but nothing changed" report comes from uploading an artifact and never deploying it. The concurrency: group: pages block ensures two pushes can't race to publish at once.
Why the Extended Edition
The extended edition bundles the libsass compiler and WebP encoder into the Hugo binary. Plain Hugo can't transpile SCSS or encode WebP, so a theme using either fails the build with error: ... TOCSS: failed to transform or image processing not available. Setting extended: true in peaceiris/actions-hugo is the fix — and it's why pinning hugo-version matters: an unpinned install can drift to a release with different image-processing defaults.
Caching Hugo Resources
If your site processes images or compiles SCSS, cache Hugo's resource cache so those artifacts aren't regenerated on every run:
- name: Cache Hugo resources
uses: actions/cache@v4
with:
path: |
resources/_gen
~/.cache/hugo_cache
key: ${{ runner.os }}-hugo-${{ hashFiles('content/**', 'assets/**', 'config.*') }}
restore-keys: ${{ runner.os }}-hugo-
resources/_gen holds the output of Hugo's image processing and asset pipeline — the expensive, deterministic-per-input work. Keying on content and asset hashes (not the lockfile, since Hugo has none) means the cache survives unrelated commits and only refreshes when the inputs change. The restore-keys fallback lets a near-miss warm-start instead of rebuilding from cold. This is the same discipline applied to the Node generators in Caching node_modules in GitHub Actions for Faster SSG Builds.
Environment and Config
Set HUGO_ENV=production (shown in the workflow's env block) so production-only templates and minification activate — many themes gate analytics, social cards, and minified output on this variable. Pass any secrets through the env block from repository secrets, never inline.
The one config value that breaks deploys is baseURL. Hugo resolves fingerprinted asset URLs against it at build time, so if config.toml says baseURL = "https://example.com/" but you deploy to a project subpath like example.com/docs/, every CSS and image link 404s. Keep baseURL correct in config, and pass the deployment value explicitly with --baseURL "${{ vars.BASE_URL }}" so the same workflow can target production and a staging domain without editing committed config.
Previewing Drafts on a Pull Request
The production workflow above builds only published content. To review drafts before they go live, add a second workflow on pull_request that builds with -D (include drafts) and deploys to a separate preview target rather than Pages:
on:
pull_request:
types: [opened, synchronize]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { submodules: recursive, fetch-depth: 0 }
- uses: peaceiris/actions-hugo@v3
with: { hugo-version: '0.162.0', extended: true }
- run: hugo -D --minify --baseURL "${{ vars.PREVIEW_URL }}"
# deploy ./public to a preview host (Cloudflare Pages, Netlify, etc.)
Keep production (hugo --minify) and preview (hugo -D) on separate workflows so a draft can never leak into a Pages deploy. The full per-PR preview lifecycle — unique URLs, quality gates, and teardown — is covered in Preview Environments for Pull Requests.
Measured Impact
Caching the resource cache is the difference between re-encoding every image on every run and reusing the encoded variants. Build times from the job summary, same site, warm vs cold:
| Stage | Cold (no cache) | Warm (resources/_gen cached) |
|---|---|---|
| Hugo install | 6s | 6s |
| Image + SCSS processing | 71s | 4s |
| Page render | 18s | 18s |
| Total build job | ~95s | ~28s |
The render step doesn't change — Hugo is already fast at rendering Markdown — but the image and SCSS line collapses from 71s to 4s because the encoded variants come straight from cache. On a deploy-on-every-push workflow that's roughly a minute saved per commit. The same caching pattern, in a build-then-deploy shape, is generalized in Caching Hugo Builds in GitHub Actions.
Pitfalls & Rollback
- Plain
hugoinstead ofhugo-extended: SCSS and WebP fail. Setextended: trueinpeaceiris/actions-hugo. - Artifact uploaded but site not updated: you're missing the
deployjob. Addactions/deploy-pageswithneeds: build. - 404 on deployed assets: wrong
baseURL. It must match the live domain, protocol, and any subpath exactly, because Hugo bakes it into fingerprinted asset URLs at build time. - Empty lastmod dates: the default shallow checkout has one commit, so
.GitInforeturns nothing. Addfetch-depth: 0. - "failed to load modules": a stale
go.sumwith Hugo Modules. Runhugo mod tidylocally and commit it. - Rollback: because the whole pipeline is the committed workflow file plus the
deploy-pagesenvironment, reverting a bad deploy is a git revert and a re-run — GitHub Pages republishes the prior artifact, with no cache state to untangle.
Conclusion
A working Hugo Pages pipeline is two jobs: build with peaceiris/actions-hugo (extended: true, fetch-depth: 0) and hugo --minify --gc, then publish with actions/deploy-pages gated on needs: build. Cache resources/_gen to skip re-encoding, pin baseURL and hugo-version, and every push to main ships the site in under half a minute warm. For the framework-agnostic pipeline this builds on, see GitHub Actions for Automated SSG Builds.
FAQ
Why do I need the extended edition of Hugo in CI?
The extended edition bundles the libsass compiler and WebP encoding. If your theme uses SCSS or you process images to WebP, plain Hugo fails the build with an error about transpilation or image processing not being available. Set extended: true in the install action.
Do I really need fetch-depth zero?
Only if you use Hugo's .GitInfo for lastmod dates or word counts from git history. The default shallow checkout has one commit, so .GitInfo returns empty dates. It does not enable incremental builds — it only restores the git history Hugo reads from.
Why is my deployed site missing CSS and images?
Almost always a baseURL mismatch. Hugo bakes baseURL into asset links at build time, so if it does not match the live domain and any subpath exactly, every fingerprinted asset 404s. Set it in config and pass the matching value in CI.
How much does caching resources/_gen save?
It skips re-encoding processed images and recompiling SCSS on every run. On a site processing around 600 images the build dropped from roughly 95 seconds to 28 seconds once the cache was warm, because Hugo reused the generated resources instead of regenerating them.
Related
- Parent: GitHub Actions for Automated SSG Builds — the framework-agnostic pipeline this specializes.
- Caching node_modules in GitHub Actions for Faster SSG Builds — the Node-generator caching companion.
- Caching Hugo Builds in GitHub Actions — generalized Hugo build caching.
- Production-Ready Deployment & CI/CD Workflows — where Hugo deploys fit the wider pipeline.