Caching Go Module Downloads with a Named Volume

Cold go mod download refetches every dependency on each rebuild, which is slow. This page mounts GOMODCACHE on a named volume so modules persist across rebuilds, and caches GOCACHE too so incremental builds stay fast — turning a minute of module fetching into seconds.

The reason this matters is structural rather than incidental. A devcontainer's writable layer is thrown away every time you rebuild the image, and a rebuild is not a rare event: bumping the Go Feature version, editing the Dockerfile, changing a base image tag, or simply choosing "Rebuild Container" from the command palette all wipe whatever go mod download fetched last time. Because Go stores its module cache under GOMODCACHE (default /go/pkg/mod) inside that ephemeral layer, every rebuild starts from an empty directory and the toolchain dutifully walks your entire dependency graph again, contacting proxy.golang.org for each module it can't find locally. On a project with a few dozen transitive dependencies that is tens of megabytes over the network and a noticeable stall before the container is usable.

Reach for a named volume the moment rebuilds stop being a once-a-week event and start being part of your inner loop — when you are iterating on the container definition itself, onboarding teammates who each pay the cold-download cost, or working where bandwidth is scarce. The mental model is a two-tier split: the container image stays disposable and reproducible, while the download cache lives in a persistent, container-managed volume that outlives any single build. You are not making the environment less reproducible by doing this; go.sum still governs which bytes are allowed into the cache, so the volume is a pure performance layer under an unchanged correctness guarantee.

Prerequisites

You need a Go devcontainer and a named volume to hold the module cache.

  • A Go Feature pinned to a version.
  • GOMODCACHE set to a known path (default /go/pkg/mod).
  • A named volume to mount at that path.

Pinning the Go Feature to an explicit version matters more than it looks. If you let the Feature float to latest, the toolchain version can shift out from under you, and while a mismatched toolchain won't corrupt the module cache, it does mean the GOCACHE build artifacts you persisted become useless the moment the compiler changes — build output is keyed by the exact compiler version. Pinning 1.22 (or whatever your go.mod declares) keeps both caches warm across rebuilds instead of silently invalidating half of them.

The detail people most often get wrong is the relationship between GOMODCACHE the environment variable and the mount target. They have to name the same path. It is easy to set GOMODCACHE to /go/pkg/mod in containerEnv but mount the volume at, say, /home/vscode/go/pkg/mod because that is where a different base image put GOPATH. When the two disagree, Go writes modules to the env-var path while your volume sits mounted somewhere else entirely, and nothing persists. Decide on one canonical path, set the variable to it, and mount the volume at exactly that string.

Cache prerequisitesSet GOMODCACHE, mount a volume there, populate once, and reuse on rebuilds.GOMODCACHEknown pathVolumemount therego mod downloadpopulate onceRebuildreuse cache

Step-by-Step Implementation

  1. Set GOMODCACHE and mount a volume at that path.
{
  "features": { "ghcr.io/devcontainers/features/go:1": { "version": "1.22" } },
  "containerEnv": { "GOMODCACHE": "/go/pkg/mod" },
  "mounts": ["source=devcontainer-go-modcache,target=/go/pkg/mod,type=volume"],
  "remoteUser": "vscode"
}

This block does three coordinated things. containerEnv sets GOMODCACHE to /go/pkg/mod so every go invocation inside the container — whether it comes from you, from gopls, or from a postCreateCommand — agrees on where modules live. The mounts entry declares a named volume, devcontainer-go-modcache, with type=volume and target set to that same path, which is what makes the cache outlast the disposable image layer. Naming the source explicitly rather than using an anonymous volume is deliberate: a named volume is easy to inspect with docker volume ls and can be shared or reset on purpose. The remoteUser line matters here too, because the process that writes to the volume runs as vscode and the volume's ownership needs to match — the failure this prevents is a first rebuild that mounts an empty root-owned volume the vscode user cannot write to.

  1. Populate the cache on create.
{ "postCreateCommand": "go mod download" }

postCreateCommand runs once after the container is created, which is the right moment to warm the cache: the volume is mounted, GOMODCACHE is set, and the workspace with its go.mod and go.sum is available. Running go mod download here fetches every dependency listed in go.mod (and their transitive requirements) into the volume up front, so the first go build or the first time gopls indexes the workspace finds everything already local instead of stalling on network fetches while you wait. On a warm volume — a second or later rebuild — this command is nearly instantaneous because the modules are already present and their checksums already verified, so it costs almost nothing to leave in place. Keeping it in postCreateCommand rather than postStartCommand also avoids re-running it on every container start, which would be wasted work once the cache is warm.

  1. Cache the build cache too for fast incremental builds.
{ "containerEnv": { "GOCACHE": "/home/vscode/.cache/go-build" },
  "mounts": ["source=devcontainer-go-build,target=/home/vscode/.cache/go-build,type=volume"] }

The module cache solves fetching, but it does nothing for compilation — that is what GOCACHE is for. Go stores the compiled output of every package, keyed by source content and compiler flags, under GOCACHE (here /home/vscode/.cache/go-build), and a warm build cache is why the second go build in a session is dramatically faster than the first. Without persisting it, every rebuild throws that compiled output away and forces Go to recompile the entire dependency tree from scratch even though the modules themselves were already downloaded. Note the path lives under the vscode home directory rather than under /go, which is intentional: GOCACHE is per-user machine state, and putting it in the user's home keeps its ownership aligned with remoteUser for free. Give it a separate named volume from the module cache — devcontainer-go-build — so you can prune stale build output after a compiler upgrade without discarding the far more expensive-to-refetch modules.

  1. Verify the cache survives a rebuild.
du -sh /go/pkg/mod   # non-empty after a rebuild, no re-download

du -sh on the GOMODCACHE path is the fastest honest check: rebuild the container, open a terminal, and run it. A non-empty directory that reports the same size as before the rebuild proves the volume mounted and the modules survived, whereas a size of a few kilobytes or an empty listing means the mount silently failed and Go is about to re-download everything. For a second signal, run go build ./... twice and watch the second run finish in a fraction of the first's time — that gap is the GOCACHE volume doing its job. To confirm no network traffic at all, GOFLAGS=-mod=readonly go build ./... on a warm cache completes without contacting the proxy, and any attempt to fetch a missing module fails loudly instead of quietly downloading.

Fetch/build time by cacheCaching GOMODCACHE and GOCACHE cuts module fetch and build time sharply.Cold download62sWarm module cache7sWarm mod + build cache4sillustrative time

Common Pitfalls

Cache misses come from an unmounted GOMODCACHE or a permissions problem on the volume.

The permissions angle is the one that bites hardest, because a fresh named volume is created owned by root. If your container runs as vscode and the very first thing to touch the volume is go mod download as that unprivileged user, the write fails with "permission denied" and the cache never populates — worse, Go may fall back to a writable location so builds seem to work while nothing is actually being cached. The clean fix is to make ownership match the running user before anything writes: a one-line postCreateCommand such as sudo chown -R vscode:vscode /go/pkg/mod /home/vscode/.cache/go-build run once brings both volumes under the right owner. Go additionally marks downloaded module files read-only on purpose, so do not "fix" a permission error by chmod-ing the cache contents themselves — the problem is directory ownership, not the file mode Go set deliberately.

The subtler, topic-specific pitfall is an unbounded cache. The module cache only ever grows: every version of every dependency you have built against accumulates under GOMODCACHE, and over months of version bumps a busy project's volume can swell to gigabytes of modules you no longer reference. It never causes wrong builds, but it does quietly consume disk. Rather than deleting the volume and throwing away the warm cache, reclaim space deliberately with go clean -modcache when the size bothers you, or go clean -cache for the build-output volume. Because both are backed by named volumes you can reason about each independently instead of nuking the whole environment.

Cache triageA triage path from re-downloading modules to persistent, writable caches.Is GOMODCACHE on a named volume?NOMount a volume at that pathCan remoteUser write the volume?YESCache GOCACHE tooFast, persistent Go caches

SymptomRoot CauseRemediation
Modules re-download every rebuildGOMODCACHE not on a volumeMount a volume at GOMODCACHE
Permission denied writing cacheVolume owned by rootchown the cache dir to remoteUser
Builds still slow after caching modulesGOCACHE not cachedCache GOCACHE on a volume too
Cache grows unboundedNever prunedOccasionally go clean -modcache when needed

Conclusion

Persist both caches. Mount GOMODCACHE on a named volume so modules survive rebuilds, and cache GOCACHE so incremental compiles are fast too. Because go.sum pins checksums, a warm cache is always safe — it can only hold the exact modules your checksums permit — so this is pure speed with no reproducibility cost.

The strategic payoff is that you decouple two things that most setups accidentally weld together: how disposable your environment is and how slow it is to rebuild. Once the two caches live on named volumes, you can treat the image itself as genuinely throwaway — rebuild it as often as you like to pick up a base-image patch or a Feature bump — without ever paying the cold-download and full-recompile tax that normally makes people avoid rebuilding. That is what turns "reproducible environment" from a principle you tolerate into one you actually reach for, because the friction that discouraged frequent rebuilds is gone.

This fits the broader pin-and-cache pattern that runs through a reliable Go devcontainer. You pin the inputs — the Go Feature version, and go.sum's checksums for every module — so that what enters the environment is deterministic, and then you cache the expensive results of processing those pinned inputs so you only pay for them once. The pinning is what makes the caching safe: a cache is only ever a shortcut to a result you could reproduce from scratch, and go.sum guarantees the shortcut and the from-scratch path land on identical bytes. Applied here, that principle means the module volume and the build volume are not a reproducibility risk to be managed but a speed win with the correctness already proven, which is exactly the property you want from any layer you leave running underneath your work.

Cache and safetyCaching modules and build output is safe because go.sum pins every checksum.CacheGOMODCACHEGOCACHEon named volumesSafe becausego.sum pins checksumsOnly allowed modulesDeterministic

FAQ

Why are my Go modules re-downloaded on every rebuild? Because GOMODCACHE lives in the container's ephemeral storage by default, so a rebuild starts empty. Mount a named volume at the GOMODCACHE path (default /go/pkg/mod), and downloaded modules persist across rebuilds, so go mod download reuses them. Double-check that the value you put in containerEnv for GOMODCACHE is byte-for-byte the same as the mount target; when they drift apart, Go writes to one location while the volume is mounted at another, and the symptom looks exactly like no cache at all even though the volume exists.

Is a warm module cache safe for reproducibility? Yes. Go's go.sum records a cryptographic checksum for every module, and the toolchain verifies against it. A cached module that doesn't match the checksum is rejected, so a warm cache can only ever contain the exact modules your go.sum allows — caching adds speed without weakening determinism. This is why persisting the cache is not a trade-off between speed and correctness the way some caches are: the worst a corrupted or tampered cache entry can do is fail verification and get refetched, never silently substitute the wrong code into your build.

Should I also cache the build cache? Yes, if build speed matters. GOCACHE holds compiled package output; caching it on a volume lets incremental builds reuse compilation across rebuilds. Together with the module cache, it keeps both fetching and compiling fast. Keep it on a separate named volume from the module cache, because build output is invalidated by a compiler-version change while modules are not — separating them lets you clear stale build artifacts after a Go upgrade without discarding the downloads.

Can I share one module-cache volume across several projects? Usually yes, and it is often a good idea. The module cache is content-addressed and checksum-verified, so two projects that depend on the same version of the same module read the identical, already-verified files — one project warms the cache and the other benefits for free. Point the same source=devcontainer-go-modcache volume at each project's GOMODCACHE and they cooperate rather than collide. The one caveat is disk: a shared volume accumulates the union of every project's dependencies, so it grows faster and you will reach for go clean -modcache sooner.

Do I still need go mod download in postCreateCommand once the volume is warm? It does no harm and it is cheap to keep. On a warm volume the command finds everything already present, verifies the checksums, and exits in a fraction of a second. Its real value is the cold case — a brand-new volume or a teammate's first build — where it front-loads the entire download before you touch the code, so the first go build or gopls index is instant rather than stalling mid-work on network fetches.

Why put GOMODCACHE on a volume instead of a bind mount from the host? A named volume is managed by the container runtime, which keeps it fast and avoids the file-permission and performance quirks of bind-mounting a host directory into the container — especially painful across the host boundary on macOS and Windows. For a cache that only the container needs to read and write, the runtime-managed volume is the simpler and faster choice.