Caching Hugo Builds in GitHub Actions
Hugo is famously fast at rendering Markdown, so people are often surprised when their GitHub Actions build is slow. The render is rarely the bottleneck — it is the asset processing. Resizing images, compiling Sass, and fetching Hugo Modules all happen on every cold runner unless you cache them. The good news is that Hugo stores those results in predictable, content-hashed directories, so caching them in CI is both safe and effective. This guide gives the workflow, the cache key design, and the measured CI minutes saved. It is the Hugo companion to Incremental Builds and Build Caching for SSGs.
Prerequisites
- A Hugo site building in GitHub Actions (extended Hugo if you compile Sass/SCSS).
- Asset processing worth caching — image
Resize/Filloperations,resources.ToCSS, or Hugo Modules. A pure-Markdown site with a vendored theme gains little. actions/cache@v4available (it is, on GitHub-hosted runners).
What Hugo Caches and Where
Two directories carry the cost between builds:
resources/_gen— processed images and compiled stylesheets, each named by a hash of its source plus the transform options. Because the names are content-hashed, a restored cache is correct by construction: an entry only matches when the source and options are byte-for-byte identical, so Hugo regenerates exactly what changed and reuses the rest. This is the same fingerprinting logic that makes proper cache headers on Netlify safe at the edge.- The Go module cache — if you use Hugo Modules, dependencies are downloaded into the module cache (
$HOME/go/pkg/modand Hugo's own module cache). Restoring it skips the network fetch on every run.
The Recipe
Add two cache steps before the build — one for resources/_gen, one for the module cache — each keyed on the inputs that determine its contents:
name: Build Hugo
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive # theme submodules
- name: Cache Hugo processed resources
uses: actions/cache@v4
with:
path: resources/_gen
key: hugo-gen-${{ runner.os }}-${{ hashFiles('assets/**', 'config/**', 'hugo.toml') }}
restore-keys: |
hugo-gen-${{ runner.os }}-
- name: Cache Hugo module cache
uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/hugo_cache
key: hugo-mod-${{ runner.os }}-${{ hashFiles('go.sum') }}
restore-keys: |
hugo-mod-${{ runner.os }}-
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: '0.140.0'
extended: true
- run: hugo --gc --minify
The resources/_gen key hashes assets/**, your config, and hugo.toml, so any change to a source image, a Sass file, or a transform option busts only the relevant entry. The module cache keys on go.sum, so it is reused until your module versions change. The restore-keys prefixes keep a near-miss warm rather than starting cold — the same key discipline laid out in Caching node_modules in GitHub Actions for Faster SSG Builds.
Measured Impact
Benchmarked on a Hugo site with ~600 pages and ~180 source images doing Resize/Fill plus Sass compilation, built with extended Hugo 0.140 on ubuntu-latest. Times are from the GitHub Actions run summary:
| Scenario | Build step time | Billable minutes |
|---|---|---|
| Cold build, no cache | 95 s | 2 (rounded up) |
| Warm cache, content edit (one post) | 25 s | 1 |
| Warm cache, one new image added | 31 s | 1 |
| Cache key change (config edited) | 95 s | 2 |
On the common case — a content edit with no asset change — caching resources/_gen cut the build step from 95 s to 25 s, about a 74% reduction, and dropped the billable minute count from 2 to 1. Across a team merging 40 PRs a week, that is roughly 40 billable minutes saved per week on this one job, with restores adding only a few seconds of overhead. The cache-key-change row is the honest ceiling: editing the config or a transform option correctly reprocesses everything, because the inputs really did change.
Pitfalls & Rollback
- Caching the wrong path. Caching
public/ornode_modulesdoes nothing for Hugo's asset cost. Cacheresources/_genand the module cache specifically. - Keys that never invalidate. A fixed-string key serves stale processed assets forever. Always hash
assets/**and config into theresources/_genkey. - Forgetting theme submodules. If your theme is a git submodule,
submodules: recursiveon checkout is required or the build fails before caching matters. - Cache eviction. A repo's caches share a 10 GB budget; a bloated
resources/_genfrom huge originals can evict your module cache. Keep source images reasonable. - Rollback: delete the two
actions/cachesteps and the build runs cold every time — correct, just slower. There is no persisted state to clean up beyond letting the old caches expire.
Conclusion
Hugo's speed reputation is about rendering, not asset processing — and asset processing is what a cold CI runner redoes every time. Cache resources/_gen keyed on a hash of your assets and config, cache the Go module cache keyed on go.sum, and a routine content edit drops from a 95 s cold build to a 25 s warm one. Because Hugo content-hashes everything in resources/_gen, the restored cache is always correct. For the cross-generator picture, see Incremental Builds and Build Caching for SSGs; for the surrounding pipeline, GitHub Actions for Automated SSG Builds.
FAQ
What does Hugo store in resources/_gen?
Processed image variants, compiled Sass and SCSS, and other transformed assets, each named by a hash of its source plus the transform options. Because the filenames are content-hashed, restoring the directory in CI is safe — a stale entry simply never matches a new request, so Hugo regenerates only what actually changed.
Should I cache the Hugo binary itself?
It helps marginally. The bigger wins are resources/_gen and the Go module cache. If you install Hugo through a setup action it is already fast, but caching the extended binary download shaves a few seconds on every run for no real downside.
Why is my Hugo cache restoring but the build still slow?
Either the cached path does not match Hugo's actual cache directory, or the cache key changes on every run. Confirm you are caching resources/_gen and the module cache, key them on a hash of assets and go.sum respectively, and check the runner log for a restored rather than created entry.
Do I still need --gc or --minify with caching?
Yes, those flags are about output cleanup and asset minification, not caching. Keep them in your build command. Caching speeds up the inputs to the build; --gc and --minify shape the output and run regardless.
Does this help if my site has no images or Sass?
Less so. resources/_gen is empty if you do no asset processing, so the main remaining win is the Go module cache for sites using Hugo Modules. A pure Markdown site with a vendored theme builds fast already and gains little from caching.
Related
- Parent: Incremental Builds and Build Caching for SSGs — incremental vs. caching across generators.
- Enabling Incremental Builds in Eleventy — the per-run incremental side for Eleventy.
- Sharing Build Cache Across CI Runners — when a per-repo cache is not enough.
- GitHub Actions for Automated SSG Builds — the surrounding build-and-deploy pipeline.