Hugo Image Processing Cache in CI

Hugo's image processing is one of its best features: .Resize, .Fill, .Process and friends produce responsive variants and modern formats straight from templates, without an external pipeline. It is also, on image-heavy sites, the single largest cost in a cold build. Resizing a 4,000-pixel screenshot into six widths and three formats takes real CPU, and a site with hundreds of images asks for thousands of variants. Hugo caches every variant it produces, so a second local build is fast — but CI runners start empty, and without a persisted cache every CI build pays the full price.

This guide persists Hugo's image cache between CI runs, keys it so it stays valid, keeps it from growing without bound, and measures the effect on a documentation site. It is part of Hugo Build Times for Large Repositories, and the image templates themselves are covered in Optimizing WebP Images in Hugo Without Plugins.

Prerequisites

  • Hugo extended 0.123+ (for WebP encoding).
  • Templates that process images with Hugo Pipes — here a render hook that turns every Markdown image into a <picture> with AVIF-free WebP and JPEG variants at five widths.
  • GitHub Actions or another CI with a directory cache.

How the Cache Works

When a template calls $img.Resize "800x webp q80", Hugo computes a key from the source image's content hash and the processing specification, and looks for a file with that key in the images cache. On a hit it copies the cached file; on a miss it decodes, processes and encodes the image, then writes it to the cache. Because the key includes the source content hash, replacing an image automatically invalidates its variants, and changing a quality setting produces new keys.

By default, the cache lives in the project under resources/_gen/images. Configuring the file cache moves it:

# hugo.toml
[caches]
  [caches.images]
    dir = ":cacheDir/images"
    maxAge = "720h"
  [caches.getresource]
    dir = ":cacheDir/getresource"
    maxAge = "24h"

:cacheDir resolves to the HUGO_CACHEDIR environment variable, or a default outside the project. Putting the images cache there lets one CI cache step cover images, remote resources and modules together.

Cache lookup for a Hugo image processing call A template calls Resize with a width, format and quality. Hugo hashes the source image content together with the processing options to form a key. It looks for the key in the images cache. On a hit it copies the cached variant in milliseconds. On a miss it decodes, resizes, encodes and stores the variant, which took about 190 milliseconds per variant on average here. One image request in a Hugo template Resize call 800x webp q80 Compute key source hash + options Cache lookup images cache dir Hit copy in about 1 ms Miss decode, resize, encode ~190 ms
Because the key includes the source hash, a replaced image never reuses stale variants.

Measuring the Cost

The documentation site has 640 source images, mostly PNG screenshots, and the render hook produces five widths in WebP and JPEG for each: about 6,400 variants, of which 5,100 are unique after deduplication of small images that do not need every width.

BuildVariants processedBuild time
Cold (empty cache)5,1003 min 12 s
Warm (full cache, no changes)013 s
Warm, 12 new screenshots in the PR9631 s

Measured with hyperfine on a GitHub Actions ubuntu-latest runner (4 vCPU). Image processing was 93% of the cold build.

Hugo build time with cold, warm and partially warm image caches Vertical bars of build time. A cold build with an empty image cache took 192 seconds. A warm build with no image changes took 13 seconds. A warm build where the pull request added 12 screenshots took 31 seconds. Build time by cache state 192 s Cold cache 31 s Warm, 12 new images 13 s Warm, no image changes
2,300 pages, 640 source images, 5,100 processed variants, 4-vCPU runner.

Persisting the Cache in GitHub Actions

Restore and save the cache directory around the build. The key needs to change when images or processing change, but a fresh key on every commit would mean saving a new multi-hundred-megabyte cache each time. The pattern that works: a key based on a hash of image sources and templates, with a fallback prefix so partial matches still restore most variants.

- uses: actions/cache@v4
  with:
    path: ${{ runner.temp }}/hugo_cache
    key: hugo-${{ runner.os }}-${{ hashFiles('assets/images/**', 'content/**/*.png', 'content/**/*.jpg', 'layouts/_default/_markup/render-image.html') }}
    restore-keys: |
      hugo-${{ runner.os }}-
- run: hugo --gc --minify
  env:
    HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache

On a pull request that adds images, the exact key misses, the prefix restores the most recent cache, Hugo processes only the new images, and the job saves a new cache under the new key. On a pull request that changes only text, the exact key hits and nothing is saved.

GitHub Actions caches are scoped by branch, with pull requests able to read caches from the base branch. Make sure a build on main runs after merges so the base-branch cache stays current; otherwise every pull request restores an increasingly old cache and processes more images than necessary.

Keeping the Cache Small

Without cleanup, the cache accumulates variants for images that were deleted or re-exported, and for old quality settings. After six months without cleanup, the cache on this site had grown to 1.9 GB for a site that needed 410 MB of variants, and restoring it took longer than processing the images would have.

Two settings fix it. hugo --gc removes cache entries not used by the current build at the end of each run. maxAge on the images cache expires entries older than the given duration even if --gc is not used. With both, the persisted cache stayed within a few percent of the live variant set.

Image cache size and restore time with and without garbage collection Two panels. Without cleanup, after six months the cache held variants for deleted images and old quality settings, grew to 1,900 megabytes and took 46 seconds to restore. With --gc and maxAge, it held only variants used by the current build, stayed at 430 megabytes and restored in 9 seconds. Cache size and restore time No cleanup variants of deleted images kept old quality settings kept grows with every re-export 1,900 MB, 46 s restore --gc and maxAge 720h unused variants removed each build entries expire after 30 days tracks the live variant set 430 MB, 9 s restore
Restore time is the actions/cache step duration on ubuntu-latest.

Committing resources/_gen Instead

Some Hugo sites commit resources/_gen to the repository — Hugo's own documentation site historically did — so that any build, including on platforms with no cache configuration, gets processed images for free. For small sites that is pragmatic. For this site it meant 410 MB of binary files in Git, growing with every screenshot change, and every clone downloading them. Netlify and Cloudflare Pages both offer build caches that can persist a directory between deploys; using those, or building in GitHub Actions and deploying the output, avoids committing generated files.

Processing Less in the First Place

Caching makes repeat work free; not doing the work is better still. Three changes reduced the cold build as well: generating AVIF only for hero images rather than every screenshot (AVIF encoding was about four times slower than WebP here); skipping widths larger than the source image; and resizing screenshots with the Lanczos filter only above 1,600 pixels and Box below, which was visually indistinguishable and 30% faster. The cold build went from 3 min 12 s to 2 min 5 s, which matters for the first build on a new branch and for cache misses after a template change.

Netlify and Cloudflare Pages Builds

When the host runs the build instead of GitHub Actions, the same cache can be persisted with the host's mechanism. Netlify's build image caches resources/ automatically for Hugo sites detected by its build system, and the netlify-plugin-cache plugin can persist any other directory, such as a custom HUGO_CACHEDIR. Cloudflare Pages keeps a build cache for supported frameworks; for Hugo, keeping the images cache under resources/_gen (the default) and enabling build caching in the project settings achieved warm builds of about 20 seconds.

Measured Impact

MeasureBeforeAfter
CI build, typical text-only pull request3 min 12 s13 s
CI build, pull request adding images3 min 12 s31 s
Cold build (cache miss)3 min 12 s2 min 5 s
Persisted cache size—430 MB
CI minutes per month (docs site)1,850190

Pitfalls & Rollback

  • Cache without --gc. Grows until restoring it is slower than rebuilding.
  • Key that never changes. New variants are processed but never saved; key on image and template hashes.
  • Stale base-branch cache. Build main after merges so pull requests restore a recent cache.
  • Different Hugo versions sharing a cache. Processing results can differ between versions; include the Hugo version in the key when upgrading.
  • Rollback: remove the cache step; builds become slow but correct, since Hugo always processes missing variants.

Conclusion

Hugo's image cache is content-addressed, which makes it safe to persist between CI runs. Point it at HUGO_CACHEDIR, restore and save it with a key built from image and template hashes plus a prefix fallback, and run with --gc and a maxAge so it stays small. On this site that turned a three-minute build into thirteen seconds for most pull requests.

FAQ

Where does Hugo store processed images?

In the resources directory, resources/_gen/images by default, or under the cache directory when the images file cache is configured to use it. Each processed variant is stored under a name derived from the source image and the processing options, so it can be reused across builds.

Should resources/_gen be committed to Git?

Some teams commit it to make builds fast without a CI cache. It works, but on image-heavy sites it adds hundreds of megabytes to the repository and churns on every image change. Caching the directory in CI gives the same speed without the repository growth.

How much faster are builds with a warm image cache?

On the documentation site measured here, with 640 source images and about 5,100 processed variants, a cold build took 3 minutes 12 seconds and a warm build 13 seconds.

How do I stop the cache from growing indefinitely?

Run hugo with --gc, which removes unused cached files at the end of the build, and set a maxAge for the images cache. Both keep the persisted cache limited to variants the current site actually uses.

Filed underHow-tosHugo