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/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).

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.

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.

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.

Static Site Generators in Production