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.
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.
| Build | Variants processed | Build time |
|---|---|---|
| Cold (empty cache) | 5,100 | 3 min 12 s |
| Warm (full cache, no changes) | 0 | 13 s |
| Warm, 12 new screenshots in the PR | 96 | 31 s |
Measured with hyperfine on a GitHub Actions ubuntu-latest runner (4 vCPU). Image processing was 93% of the cold build.
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.
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
| Measure | Before | After |
|---|---|---|
| CI build, typical text-only pull request | 3 min 12 s | 13 s |
| CI build, pull request adding images | 3 min 12 s | 31 s |
| Cold build (cache miss) | 3 min 12 s | 2 min 5 s |
| Persisted cache size | — | 430 MB |
| CI minutes per month (docs site) | 1,850 | 190 |
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
mainafter 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.
Related
- Parent: Hugo Build Times for Large Repositories — where time goes in large Hugo builds.
- Hugo Modules and Dependency Caching — the same cache directory for modules.
- Caching Hugo Builds in GitHub Actions — the full caching workflow.
- Caching Processed Images Between Builds — the same idea in Astro and Eleventy.
- Reducing Hugo Memory Usage on CI Runners — image processing and memory.