Hugo Modules and Dependency Caching

Hugo builds are famously fast — thousands of pages in seconds — which makes everything around the render step stand out. On larger organisations' sites, the part of the CI build that often takes longest is not rendering at all: it is fetching the theme and shared components as Hugo Modules, which means Go downloads each module and its dependencies from GitHub on every run. On a documentation site measured for this guide, module fetching took 19 seconds against an 11-second render.

This guide explains how Hugo Modules work, how to structure a theme and shared components as modules, how to pin them reproducibly, and how to cache or vendor them so CI builds do not re-download anything. It is part of Hugo Build Times for Large Repositories. The site is a Hugo 0.134 documentation site of 2,300 pages that imports a company theme, a shared "components" module used by four sites, and two third-party modules for diagrams and icons.

Prerequisites

  • Hugo extended 0.123+ and Go 1.22+ available locally and in CI.
  • A site initialised as a module: hugo mod init github.com/example/docs.
  • GitHub Actions (the caching approach applies to any CI with a cache mechanism).

How Hugo Modules Work

A Hugo site that is a module declares imports in its configuration. Each import is a Go module path, and Hugo mounts directories from it into the site's virtual filesystem — layouts, assets, content, data, static, i18n, archetypes:

# hugo.toml
[module]
  [module.hugoVersion]
    min = "0.123.0"
    extended = true
  [[module.imports]]
    path = "github.com/example/docs-theme"
  [[module.imports]]
    path = "github.com/example/hugo-components"
    [[module.imports.mounts]]
      source = "layouts/shortcodes"
      target = "layouts/shortcodes"
  [[module.imports]]
    path = "github.com/example/hugo-icons"

Files in the site override files at the same path in imported modules, and earlier imports override later ones, so a site can replace one partial from the theme without forking it. Versions are recorded in go.mod and checksums in go.sum, exactly as for Go code, which makes theme updates explicit, reviewable changes rather than moving submodule pointers.

Hugo module mount precedence Four layers from highest to lowest precedence. The site's own files override everything. The docs theme module comes next. The shared components module provides only shortcodes, mounted into layouts/shortcodes. The icons module provides assets. Hugo merges them into one virtual filesystem, with the first match winning for any path. How modules layer into one virtual filesystem Site files layouts/, assets/, content/ highest precedence docs-theme module v2.8.1 layouts, assets, i18n hugo-components module v1.14.0 shortcodes mount only hugo-icons module v0.9.3 assets/icons, lowest
Overriding one theme partial means creating the same path in the site; no fork, no submodule edits.

Pinning and Updating

go.mod records the version of each module; go.sum records hashes that make a download verifiable. Commit both. Update deliberately:

hugo mod get -u github.com/example/docs-theme@v2.9.0   # one module, explicit version
hugo mod get -u ./...                                    # everything, latest minor/patch
hugo mod tidy                                            # drop unused entries from go.mod/go.sum
hugo mod graph                                           # show the resolved dependency tree

For modules without tagged releases, Go uses pseudo-versions (v0.0.0-20260911083012-3a9f2c41b7de), which are still exact. Renovate and Dependabot both understand go.mod, so theme updates arrive as pull requests with a preview deploy, and the snapshot tests from Snapshot Testing Build Output show exactly what changed in the output.

Where the Time Goes

With no module cache, hugo --gc --minify first resolves and downloads every module. On a GitHub Actions ubuntu-latest runner:

StepTime
Resolve module graph (go list -m)4.1 s
Download 4 direct + 7 indirect modules14.6 s
Extract into module cache0.8 s
Hugo render (2,300 pages)11.2 s
Total30.7 s

The downloads go to Hugo's module cache — by default $HOME/.cache/hugo_cache/modules on Linux, configurable with cacheDir or the HUGO_CACHEDIR environment variable. Each CI run starts with an empty home directory, so every build re-downloads everything.

Time per step of a Hugo build with a cold module cache Horizontal bars of seconds per step. Downloading modules 14.6 seconds. Hugo rendering 2,300 pages 11.2 seconds. Resolving the module graph 4.1 seconds. Extracting into the cache 0.8 seconds. A cold CI build, step by step Download modules 14.6 s Render 2,300 pages 11.2 s Resolve module graph 4.1 s Extract into cache 0.8 s
GitHub Actions ubuntu-latest, median of five runs; module work was 64% of the build.

Option 1: Cache the Module Cache

Point Hugo's cache at a known directory and restore it with actions/cache, keyed on go.sum so the cache changes exactly when module versions change:

- uses: actions/setup-go@v5
  with: { go-version: '1.23', cache: false }
- uses: peaceiris/actions-hugo@v3
  with: { hugo-version: '0.134.3', extended: true }
- uses: actions/cache@v4
  with:
    path: ${{ runner.temp }}/hugo_cache
    key: hugo-modules-${{ hashFiles('go.sum') }}
    restore-keys: hugo-modules-
- run: hugo --gc --minify
  env:
    HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache

With a cache hit, resolution uses local files and downloads nothing; the build took 12.1 s total. The same directory also holds Hugo's image processing cache when resources is configured to use it, which compounds the saving — see Hugo Image Processing Cache in CI. Setting GOFLAGS=-mod=mod is unnecessary; Hugo manages that itself.

Option 2: Vendor the Modules

hugo mod vendor copies every module into _vendor/ in the repository. Hugo then uses the vendored copies and never touches the network or needs Go:

hugo mod vendor
git add _vendor go.mod go.sum

Vendoring makes the build hermetic: a GitHub outage or a deleted upstream repository cannot break it, and a pull request that updates the theme shows the theme's diff directly. The costs are repository size (the four modules added 6.4 MB, mostly icon SVGs) and remembering to re-vendor after every hugo mod get. A CI check that runs hugo mod vendor and fails if git status shows changes catches forgotten re-vendoring.

Comparison of caching and vendoring Hugo modules Two panels. Caching the module cache keeps the repository small, needs Go and network on a cache miss, updates with hugo mod get alone, and builds in 12.1 seconds on a hit. Vendoring adds 6.4 megabytes to the repository, builds fully offline without Go, requires re-vendoring after updates and builds in 11.6 seconds. Cache or vendor? Cache HUGO_CACHEDIR repository stays small needs Go + network on a miss update with hugo mod get cache keyed on go.sum 12.1 s per build on a hit Vendor into _vendor/ +6.4 MB in the repository no Go or network needed re-vendor after each update theme diff visible in PRs 11.6 s per build
The four-site organisation vendored in the three sites built in a restricted network and cached in the fourth.

Private Modules

Shared company modules often live in private repositories. Go fetches them over HTTPS and needs credentials and a hint that they should not be looked up in the public checksum database:

env:
  GOPRIVATE: github.com/example/*
- run: git config --global url."https://x-access-token:${{ secrets.MODULES_TOKEN }}@github.com/".insteadOf "https://github.com/"

Use a fine-grained token with read-only access to the module repositories. With vendoring, private modules need credentials only when someone runs hugo mod get, not in CI at all — one of the stronger arguments for vendoring shared private components.

Local Development Against a Module

When working on the theme and a site together, a replace directive points the site at a local checkout without publishing a version:

[module]
  replacements = "github.com/example/docs-theme -> ../docs-theme"

Or set HUGO_MODULE_REPLACEMENTS in the environment so the replacement never gets committed. Hugo watches the replaced directory in hugo server, so theme edits reload the site immediately.

Designing Shared Modules

Caching fixes the speed; module design decides whether sharing stays pleasant. The components module on this site started as a copy of one site's layouts/shortcodes and grew into something four sites depended on. Three rules kept it maintainable. Mount only what a module is for: the components module exposes layouts/shortcodes and assets/css/components, nothing else, so a site cannot accidentally depend on an internal partial. Tag releases with semantic versions and write a short changelog entry per release, because Renovate's pull requests link to it and reviewers read it. And give the module its own minimal example site under exampleSite/ with a CI build, so a breaking change fails in the module's repository before it reaches any consuming site.

Measured Impact

MeasureGit submodule theme (before)Modules, coldModules, cachedModules, vendored
CI build total24.8 s (incl. submodule clone 9 s)30.7 s12.1 s11.6 s
Theme update processmanual pointer bumpPR from RenovatePR from RenovatePR from Renovate + re-vendor
Sites sharing componentscopy-paste444
Builds broken by upstream outage (12 months)1—1 (cache miss during outage)0

Across the four sites and roughly 1,100 builds a month, caching and vendoring together saved about 5.6 hours of CI time per month compared with cold module fetches.

Pitfalls & Rollback

  • Cache keyed on nothing. A cache without a go.sum key may restore stale modules after an update; key on hashFiles('go.sum').
  • Forgetting to re-vendor. Add a CI check that fails when _vendor/ is out of date.
  • Public checksum lookups for private modules. Set GOPRIVATE or builds fail with 404s from the checksum database.
  • Committed local replacements. Use the environment variable, not the config file, for local replace paths.
  • Rollback: hugo mod get github.com/example/docs-theme@v2.8.1 restores the previous version; vendored sites revert the _vendor/ commit.

Conclusion

Hugo Modules turn themes and shared components into versioned dependencies with reviewable updates, and they cost download time on every uncached build. Cache the module directory keyed on go.sum, or vendor modules for hermetic, offline builds. On this site either option took the CI build from 31 seconds to about 12, and made module work a rounding error again.

FAQ

What are Hugo Modules?

Hugo Modules use Go's module system to import themes, shared layouts, content, data and assets from other repositories into a site. They replace Git submodules for themes and let several sites share components with version pinning through go.mod and go.sum.

Do I need Go installed to use Hugo Modules?

Yes, for fetching and updating modules, Hugo calls the go command. If modules are vendored into the repository with hugo mod vendor, builds no longer need Go or network access to fetch them.

Should I vendor modules or cache them in CI?

Caching the module cache directory between CI runs keeps the repository small and is usually enough. Vendoring makes builds fully offline and reproducible, and suits sites that must build in restricted networks or that want module code reviewed in pull requests.

How much time do modules add to a Hugo build?

With a cold cache, fetching modules added 19 seconds to a build in the measurements here, more than the 11-second Hugo render itself. With the module cache restored from CI cache it added under a second.

Filed underHow-tosHugo