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.
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:
| Step | Time |
|---|---|
Resolve module graph (go list -m) | 4.1 s |
| Download 4 direct + 7 indirect modules | 14.6 s |
| Extract into module cache | 0.8 s |
| Hugo render (2,300 pages) | 11.2 s |
| Total | 30.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.
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.
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
| Measure | Git submodule theme (before) | Modules, cold | Modules, cached | Modules, vendored |
|---|---|---|---|---|
| CI build total | 24.8 s (incl. submodule clone 9 s) | 30.7 s | 12.1 s | 11.6 s |
| Theme update process | manual pointer bump | PR from Renovate | PR from Renovate | PR from Renovate + re-vendor |
| Sites sharing components | copy-paste | 4 | 4 | 4 |
| 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.sumkey may restore stale modules after an update; key onhashFiles('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
GOPRIVATEor builds fail with 404s from the checksum database. - Committed local replacements. Use the environment variable, not the config file, for local
replacepaths. - Rollback:
hugo mod get github.com/example/docs-theme@v2.8.1restores 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.
Related
- Parent: Hugo Build Times for Large Repositories — the whole build-time picture.
- Caching Hugo Builds in GitHub Actions — the rest of Hugo's caches in CI.
- Hugo Image Processing Cache in CI — the other big cache.
- How to Set Up GitHub Actions for Hugo Deployments — the workflow these steps plug into.
- Pinning Actions by Commit SHA — the same reproducibility idea for CI.