Speed up Hugo builds with a warm image cache in CI

To speed up Hugo builds on a big site, fix the image cache first and the templates second, because those two hold almost all of the build time. On one of the image-heavy blogs I maintain, about 500 post bundles and 1,750 source images turn into roughly 5,700 processed files, over 300 MB in all, under resources/_gen/images. With that folder warm, a full build finishes in under two seconds; with it cold, every resize and WebP encode has to run again, and the same build takes minutes. Almost everything below is about keeping those cached files warm, in CI as well as on your laptop.

Key Takeaways

  • On an image-heavy site, a warm resources/_gen cache is what makes a Hugo build fast.
  • An actions/cache key built only from hashFiles stops saving new images once it gets a hit.
  • Key the cache on the commit SHA, restore by prefix, and run --gc on every build.
  • Ask for an image size with the same spec string in every template, so each size is made once.
  • --minify makes pages smaller, not builds faster, and it can strip quotes your tools need.

Find what is slow before you change anything

Start with Hugo’s built-in template profiler, which shows where to improve first:

hugo build --templateMetrics --templateMetricsHints

It prints one row per template, with total time, average time, max time, call count and a “cache potential” score. On Hugo’s own docs site, single.html adds up to 14.95 s over 728 page renders. Hugo renders pages in parallel, so these totals add up to more than the wall-clock time, which makes them a ranking of where to look first rather than a stopwatch.

A partial with 100% cache potential returned the same output every time it ran, which makes it a candidate for partialCached. The score only compares output, though. A partial that calls warnf or errorf, or checks .IsHome to log something, can score 100% and still break once cached, because only the first call runs, so read the template file before you switch it.

When one template is slow and you can’t see why, wrap the suspect block in a timer:

{{ $t := debug.Timer "related-posts" }}
{{ range first 5 (.Site.RegularPages.Related .) }}
  <a href="{{ .RelPermalink }}">{{ .Title }}</a>
{{ end }}
{{ $t.Stop }}

Run it with --logLevel info and the total for each named timer prints at the end.

On Windows, check your antivirus before you touch a template. Microsoft Defender can raise build times by 400% or more on some sites, because it scans every file Hugo reads and writes, and adding hugo.exe to the process exclusion list removes that overhead.

Cache partials and kill the loops that grow with every post

partialCached renders a partial once and reuses the result; any extra arguments after the context become cache keys, so you get one cached copy per distinct key:

{{ partialCached "sidebar.html" . .Section }}

On my blogs I use the same trick for anything that does not change per page: the home layout picker runs once with a fixed key like "home-layout", and the category lookup runs once per category name instead of once per post.

Slow Hugo sites usually get slower through loops in templates, and fixing them is a cheap way to improve build performance. A range .Site.RegularPages inside single.html runs once per page, for every page, so 500 posts means 250,000 passes, and the cost rises with the square of the post count. Move that work into a partialCached call keyed on something stable, or use .Related, which reads from a content index Hugo builds once.

Very large sites can also split the build with segments. You define them under [segments] in the config file, then render a subset of the content with hugo build --renderSegments <name>. That suits a large documentation site where only one section changed, though it does little for an ordinary blog.

The image cache is where large sites win or lose

Every Resize, Fit, Fill or Process call decodes the source image file, resamples it and encodes it again. Hugo keeps each result in resources/_gen/images, named by the source file plus the exact processing options, so asking for the same image with the same options again returns the cached file for free.

That only works if the options really match. On the blogs I maintain, the image render hook, the post layout, the card partials and the head preload tags all request the same three sizes, with the same quality value:

{{ $quality := site.Params.imageQuality | default 85 }}
{{ $small  := .Process (printf "resize 640x webp q%d" $quality) }}
{{ $medium := .Process (printf "resize 960x webp q%d" $quality) }}
{{ $large  := .Process (printf "resize 1400x webp q%d" $quality) }}

The featured image passes through about six templates, yet only four files exist for it: these three sizes plus one 1,600 px social card. If one template asked for q70 or resize 1400x630, Hugo would process another file for every image and keep it in the cache for good. Keep quality in one param (for example imageQuality = 65, matching [imaging] quality = 65) and the specs in one template file, so every image is processed once per size.

Rules from running this setup:

  • Changing quality, resampleFilter or a size in the spec gives every image new options, so the next build is cold. Plan it as a migration.
  • Keep source images at 1,600 px wide or less. Huge originals make the image processor run out of memory, and they slow down every resize and every image file you process from them.
  • If your pages link only the WebP versions, the CI job can delete the copied .jpg and .png original files from public/ after the build, which shrinks the upload. Only do this if no template links an original.
  • Logos, icons and flat diagrams don’t belong in the raster pipeline at all. Trace them to SVG and Hugo skips the decode and encode work.

WebP works in every current browser, so in my experience it is safe to serve WebP only, with no <picture> JPEG fallback, and the smaller image files improve page load times and site performance as well.

Speed up Hugo builds in CI with a fresh cache

Locally, the cache files live on disk between runs. CI runners start empty, so you have to save and restore resources/ yourself, and the most common recipe fails without any visible error.

Why a hashFiles key goes stale

The common example looks like this:

key: hugo-resources-${{ hashFiles('assets/**') }}

actions/cache entries can’t be changed after they are written. When the key matches exactly, the step restores the cache and then skips saving it. Adding a post doesn’t change assets/, so the key keeps matching, and every new image added since that cache was written gets processed on every run and thrown away. Each week the job gets a little slower, and nothing in the logs tells you why.

The restore-keys pattern

The workflow I use writes a new cache on every commit and restores the newest one:

- name: Cache Hugo resources
  uses: actions/cache@v5
  with:
    path: resources/
    key: hugo-resources-prod-v4-${{ hashFiles('config/**/hugo.toml') }}-${{ github.sha }}
    restore-keys: |
      hugo-resources-prod-v4-${{ hashFiles('config/**/hugo.toml') }}-
      hugo-resources-prod-v4-

The SHA makes every key unique, so the step always saves, while the prefix in restore-keys finds the most recent cache. The configuration hash comes first because a change to [imaging] makes the old files useless; bump v4 whenever you want a completely clean start.

Then run every build with --gc:

hugo --minify --gc

--gc removes cache files this build did not use and never touches the ones it did use. Running it every time keeps the saved cache the size of the live site, and it does not wipe the speed gain.

Remote resources live in a different cache

resources.GetRemote results go to the getresource cache. In Hugo 0.168 that cache defaults to :cacheDir/:project, outside resources/, so a CI step that caches resources/ misses it. Point it at the same folder:

[caches.getresource]
dir = ':resourceDir/_gen'

Now one cache step covers images, compiled CSS and remote files.

Cache the Hugo binary and pin it

Downloading Hugo on every run wastes time and lets the version drift between environments, so cache it under a key that names the exact version and install only on a miss:

- name: Cache Hugo binary
  id: cache-hugo
  uses: actions/cache@v3
  with:
    path: /usr/local/bin/hugo
    key: hugo-0.165.0-linux-amd64
- name: Install Hugo
  if: steps.cache-hugo.outputs.cache-hit != 'true'
  run: |
    curl -fsSL "https://github.com/gohugoio/hugo/releases/download/v0.165.0/hugo_extended_0.165.0_linux-amd64.tar.gz" \
      | tar -xz -C /usr/local/bin hugo

If your theme comes in as a Hugo Module, run hugo mod vendor and commit _vendor/, so CI never fetches modules over the network.

Docker builds: use a BuildKit cache mount

In a Dockerfile, resources/ vanishes with each layer rebuild unless you give it a BuildKit cache mount:

RUN --mount=type=cache,id=hugo-resources,target=/site/resources \
    hugo --minify

The mount lives outside the image layers, so the processed files survive rebuilds without inflating the final image.

Memory, timeouts and parallel rendering

Hugo renders pages in parallel by default, using every core the Go runtime can see. Since Go 1.25, the runtime reads the container’s CPU limit, so a container started with --cpus=2 gets two threads. If CI feels slow next to your laptop, check the runner’s core count before you blame Hugo.

Two settings need attention on big sites:

  • timeout limits how long one page can take to render. The default is 60s, and a cold run that encodes dozens of images for one page can go past it, so I set timeout = "300s" to keep a cold CI run from failing.
  • HUGO_MEMORYLIMIT sets, in gigabytes, how much memory Hugo may use before it starts dropping cached items. On a small runner I set HUGO_MEMORYLIMIT: 2 so Hugo stays inside the machine’s memory.

The hugo command has no true incremental build: every run renders every page again, which is why the image cache carries so much weight. hugo server is different, because its fast render mode re-renders only the pages you are looking at, and --disableFastRender turns that off when you suspect stale content. Use --poll only on network or WSL2 mounts, where file events get lost and content changes go unseen.

How to minify output in Hugo

There are two separate minify steps, and they do different jobs:

  • hugo --minify runs after rendering and shrinks every output format: HTML, CSS, JS, JSON, SVG and XML. Set minifyOutput = true under [minify] to make it the default.
  • resources.Minify (alias minify) runs inside a Hugo Pipes chain on CSS and JS files in assets/, so the minified CSS is loaded from a processed file. Files in static/ are copied as they are and never minified.

The minifier is tdewolff/minify, and it is aggressive by default: it drops attribute quotes wherever the HTML spec allows, along with optional whitespace. The result is valid HTML, yet it breaks scripts and checkers that read your markup with a regex, so I keep the quotes:

[minify.tdewolff.html]
keepQuotes = true

Minification adds a little work to every build and pays off in page weight, load time and site performance for readers. For faster builds, look to the cache and the templates instead.

Fingerprinting and Sass in Hugo Pipes

Hugo Pipes compiles Sass to CSS, bundles JS, minifies CSS and JS files and fingerprints them with no Node toolchain. A full stylesheet chain looks like this:

{{ $css := resources.Get "scss/main.scss" | css.Sass (dict "outputStyle" "compressed") | minify | fingerprint }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}" crossorigin="anonymous">

css.Sass is the current name for resources.ToCSS, and toCSS still works as an alias. fingerprint defaults to SHA-256 and puts the hash in the CSS file name. Because the URL changes whenever the content changes, you can serve these files with Cache-Control: max-age=31536000, immutable and never purge a CDN, while the integrity attribute adds a Subresource Integrity check. For JS, js.Build bundles with esbuild (set minify in its options), and resources.Concat joins plain files before minify | fingerprint.

Compiled CSS files land in resources/_gen/assets, usually a few hundred KB at most, so the same CI cache covers the CSS output and a warm build skips the Sass compile too. If you need data charts as well, Hugo can render SVG charts at build time with no chart library in the browser. For heavy work that has to run in the browser, a small Rust module compiled to WebAssembly is the next step up.

A checklist, in order of payoff

  1. Run hugo build --templateMetrics --templateMetricsHints and note the top five templates.
  2. Exclude hugo.exe from antivirus on Windows.
  3. Cache any partial with full cache potential that has no side effects, using partialCached with keys.
  4. Remove loops over .Site.RegularPages from per-page templates.
  5. Use one spec string and one quality param for each image size across all templates.
  6. Keep source images at 1,600 px wide or less.
  7. Cache resources/ in CI with a SHA key plus restore-keys, and build with --gc.
  8. Move the getresource cache under :resourceDir/_gen.
  9. Pin and cache the Hugo binary, and vendor any Hugo Modules.
  10. Add --minify for page weight, with keepQuotes = true if anything reads your HTML.

Do the first four on your laptop, where hugo server shows the effect at once. Then do the CI ones, and watch the build summary to see whether processed images drop to near zero on a content-only commit, which is the clearest sign the cache works.