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, how to confirm the cache is actually restoring, and the measured CI minutes saved.

This is the caching layer that sits on top of a working pipeline: if you have not yet wired Hugo into Actions at all, start with How to Set Up GitHub Actions for Hugo Deployments and add the cache steps here on top. For the cross-generator picture of what "incremental" versus "cached" means, this 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/Fill operations, resources.ToCSS, or Hugo Modules. A pure-Markdown site with a vendored theme gains little.
  • actions/cache@v4 available (it is, on GitHub-hosted runners).
  • Knowledge of where your Hugo cache lives. By default the file caches sit under resources/_gen (governed by resourceDir) and Hugo's own cache dir resolves from --cacheDir, $HUGO_CACHEDIR, or the OS temp dir. If you have overridden any of these in hugo.toml's [caches] block, cache those paths instead of the defaults below.

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/mod and Hugo's own module cache). Restoring it skips the network fetch on every run.
GitHub Actions cache hit and miss flow for Hugo A flow showing a build that hashes assets into a cache key, then branches: on a cache hit it restores resources/_gen and processes nothing, finishing fast; on a miss it processes all assets and saves a new cache. Cache hit vs. miss on resources/_gen hash assets/** → cache key key match? HIT restore _gen process nothing build ~25s MISS process all assets save new cache build ~95s
The build hashes its assets into a cache key. A hit restores resources/_gen and processes nothing; a miss reprocesses everything and saves a fresh cache for next time.

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.

The restore-keys line is what makes the first build after a content edit fast rather than cold. When the exact key misses — because you changed one image and the hash moved — Actions falls back to the newest entry whose key starts with hugo-gen-Linux-. That is a full, slightly-stale resources/_gen from the previous run. Hugo restores it, notices that all but the one changed asset still match their content hash, reprocesses only the changed asset, and writes a new cache under the new key. Without the prefix fallback, every asset change would be a cold reprocess of the whole tree.

How GitHub Actions Scopes the Cache

A subtlety that trips up Hugo teams: GitHub Actions caches are scoped by branch, not shared globally. A cache written on a feature branch is readable on that branch and on any branch created from it, and a run can read caches from its base branch (usually main) — but two sibling feature branches cannot see each other's caches. The practical effect is that the first CI run on a brand-new PR branch restores main's cache via restore-keys, gets most of resources/_gen warm immediately, and only reprocesses what that PR actually changed.

Branch-scoped cache visibility in GitHub Actions The main branch holds a warm resources/_gen cache. Two feature branches cut from main can each restore main's cache via restore-keys, but the two sibling feature branches cannot read each other's caches. Cache visibility follows the branch tree main warm resources/_gen cache feature/login branched from main feature/search branched from main reads main's cache reads main's cache no shared cache between siblings
Branches inherit the cache of whatever they were cut from: both feature branches restore main's warm resources/_gen via restore-keys, but neither sibling can see the other's cache.

This is why keeping main green and frequently built matters for cache performance: main is the warm base every PR inherits from. It also means a cache written only on a long-lived feature branch does not speed up other people's PRs — merge to main for the cache to become shared warmth.

Verifying the Cache Actually Hits

Restoring a cache and using it are different things, so confirm both before trusting your numbers:

  • Read the runner log. The actions/cache step prints either Cache restored from key: hugo-gen-Linux-<hash> (a hit) or Cache not found for input keys … (a miss). A restore from a restore-keys prefix rather than the exact key is still a hit — it means the fallback worked.
  • Check that Hugo skipped the work. Run the build with hugo --gc --minify --templateMetrics or watch the build duration. If resources/_gen restored but the build still takes the cold-build time, Hugo is not finding the restored files — usually because the cached path does not match Hugo's real cache directory. Pin it explicitly with --cacheDir and cache that exact path.
  • Confirm the cache is being saved. actions/cache only writes a new entry when the exact key missed. If your key never changes, it saves once and then serves that first entry forever — including stale processed assets. Watch for Cache saved with key: … on runs where assets changed.

If a restore looks correct but the build is still slow, walk the same checklist described in the FAQ below — the cause is almost always a path mismatch or a key that never invalidates.

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:

ScenarioBuild step timeBillable minutes
Cold build, no cache95 s2 (rounded up)
Warm cache, content edit (one post)25 s1
Warm cache, one new image added31 s1
Cache key change (config edited)95 s2

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/ or node_modules does nothing for Hugo's asset cost. Cache resources/_gen and the module cache specifically.
  • Keys that never invalidate. A fixed-string key serves stale processed assets forever. Always hash assets/** and config into the resources/_gen key.
  • Forgetting theme submodules. If your theme is a git submodule, submodules: recursive on checkout is required or the build fails before caching matters.
  • Cache eviction. A repo's caches share a 10 GB budget; a bloated resources/_gen from huge originals can evict your module cache. Keep source images reasonable.
  • Rollback: delete the two actions/cache steps and the build runs cold every time — correct, just slower. There is no persisted state to clean up beyond letting the old caches expire.
What belongs in the cache key Four components of a good cache key: the operating system, the Hugo version, a hash of the assets directory and a hash of the configuration. Missing any one produces either a stale restore or a permanent miss. What belongs in the cache key Runner OS binaries differ Hugo version output differs Hash of assets the actual inputs Hash of config quality settings Use restore-keys for a partial match so a new asset does not throw away the whole cache.
A key that omits the version restores a cache built by a different binary; a key that includes the commit SHA never hits at all.

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.