Go Development Environment with gopls & Modules

A reproducible Go environment in a devcontainer means a pinned toolchain, a cached module store, verified checksums, and gopls resolving against the container's Go rather than a host one. This guide, under the language configurations guide, also covers the Go-specific wrinkles teams hit: slow cold module downloads, and offline or air-gapped builds where the module proxy is unreachable.

Go's module system is already deterministic — go.sum cryptographically pins every dependency — so the work here is mostly caching for speed and routing gopls correctly so the editor's analysis matches what go build sees.

Prerequisites

You need a pinned Go via the Go Feature, gopls (the language server), a named volume for GOMODCACHE, and read-only module flags so builds don't silently mutate dependencies.

  • ghcr.io/devcontainers/features/go pinned to an exact version.
  • gopls installed (the Go extension installs it, or install explicitly).
  • A named volume mounted at GOMODCACHE (default /go/pkg/mod).
  • GOFLAGS=-mod=readonly so builds fail rather than edit go.mod.

Go prerequisitesYou need a pinned Go, gopls, a GOMODCACHE volume, and readonly module flags.Go Featurepinned versiongoplslanguage serverModule cacheGOMODCACHE volumeGOFLAGSmod=readonly

The -mod=readonly prerequisite is easy to overlook and is the one that most directly protects reproducibility, so it deserves explaining before the mechanics. By default, go build and related commands will modify go.mod and go.sum when they encounter a dependency that is missing or under-specified — quietly adding a requirement or a checksum to make the build succeed. That helpfulness is a reproducibility hazard in a shared environment: a build that silently edits the module files means the dependency graph can change as a side effect of building, so two developers can end up with subtly different go.mod files. Setting GOFLAGS=-mod=readonly inverts this: the build fails rather than edits when the module files are incomplete, forcing the graph to be explicit and committed. It turns "the build might change my dependencies" into "the build uses exactly the committed dependencies or errors," which is precisely the guarantee a reproducible environment needs.

The GOMODCACHE prerequisite reflects where Go's rebuild cost actually concentrates. Go compiles fast, so the dominant repeatable cost is not compilation but fetching modules — downloading dependency source from the module proxy into GOMODCACHE (default /go/pkg/mod). On a cold container this can take a minute or more for a large dependency tree; on a warm one it is skipped entirely because the modules are already local. Mounting GOMODCACHE on a named volume is what makes that cache survive rebuilds, so the download happens once rather than every time the container is recreated. This is the Go analogue of Rust's registry cache — the store of fetched dependency sources — and caching it is the single biggest speed lever in a Go devcontainer.

The gopls prerequisite carries the same toolchain-alignment requirement that governs every language server in this section. gopls is Go's language server, and it resolves symbols, types, and imports by reading the same modules and toolchain the compiler uses — so it must see the container's GOROOT, GOPATH, and GOMODCACHE, not a host's. When gopls is installed into the container (the Go extension does this) and the environment variables point at the container's paths, its analysis is a faithful preview of what go build will do. The prerequisite is therefore not just "install gopls" but "install it where it resolves the container's Go and modules," because a gopls looking at a different toolchain or module set produces editor diagnostics that disagree with the compiler — the exact confusion the alignment is meant to prevent.

Architecture & Configuration Deep Dive

Four layers again. The Go runtime is Feature-pinned. Modules resolve from go.mod with checksums verified against go.sum. GOMODCACHE holds the downloaded modules, cached on a named volume so rebuilds reuse them. And gopls must route against the container's Go toolchain and module cache, so its symbol resolution matches the compiler's.

Go environment layersA pinned toolchain, resolved modules, a cached GOMODCACHE, and gopls routed in-container.Go runtimeFeature-pinned toolchainModulesgo.mod + go.sum resolveGOMODCACHEcached on a named volumegoplsroutes against the container toolchain

gopls routing is the subtle part. If the Go extension is somehow pointed at a host toolchain or a different GOPATH, gopls indexes a different module set than go build compiles, producing editor diagnostics that disagree with reality. Keeping GOPATH, GOMODCACHE, and the toolchain all inside the container keeps them aligned. Caching the module store is detailed in caching Go module downloads with a named volume.

The determinism Go gives you for free through go.sum is worth understanding precisely, because it is what makes the caching safe and shapes what work remains. go.sum records a cryptographic checksum for every module version in your dependency graph, and the Go toolchain verifies each downloaded module against that checksum before using it — so a module that does not match is rejected, and the exact bytes of every dependency are pinned. This means the dependency graph is fully determined by the committed go.mod and go.sum, independent of when or where the build runs. Because of that, the devcontainer work is not about achieving reproducibility — Go's module system already did — but about making the already-deterministic build fast (caching) and keeping the editor honest (gopls routing). The checksum verification is also why a warm module cache is never a reproducibility risk: a cached module can only be one whose checksum go.sum already records.

The gopls-alignment concern is subtle because gopls failures are misleading rather than obviously broken. When gopls resolves against a different GOPATH or toolchain than the compiler, it indexes a different set of modules, so it can report an import as unresolved that go build compiles fine, or offer completions from a version of a dependency the build does not use. The symptom presents as "the editor is wrong" rather than "the environment is misconfigured," which sends people looking in the wrong place. Keeping GOROOT, GOPATH, and GOMODCACHE all inside the container — so gopls and the compiler read identical inputs — is what makes gopls's view a true reflection of what will compile. The alignment is not a nicety; it is what makes editor diagnostics trustworthy enough to act on.

Go's toolchain also has a modern wrinkle worth noting: recent Go versions can auto-download a different toolchain version if go.mod specifies one newer than what is installed, via the toolchain directive. In a devcontainer this can surprise you — the Feature pins one version, but a go directive in go.mod requesting a newer toolchain can cause Go to fetch and use that instead, diverging from what you pinned. For strict reproducibility, either keep the go.mod toolchain directive in sync with the Feature-pinned version, or set GOTOOLCHAIN to pin the behavior, so the container uses exactly the toolchain you intended rather than silently upgrading. This is the Go equivalent of Rust's rust-toolchain.toml interaction, and getting it right ensures the pinned version is the version that actually runs.

Step-by-Step Implementation

Pin Go, download modules into the cached volume in postCreateCommand, ensure gopls routes to the container toolchain, and verify a build.

Setup flowPin Go, download modules into the cache, route gopls, then verify the build.Pin GoFeature versiongo mod downloadpopulate cacheRoute goplscontainer GOPATHVerifygo build ./...

{
  "name": "Go + gopls",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED",
  "features": { "ghcr.io/devcontainers/features/go:1": { "version": "1.22" } },
  "containerEnv": { "GOFLAGS": "-mod=readonly", "GOMODCACHE": "/go/pkg/mod" },
  "customizations": {
    "vscode": { "extensions": ["golang.go"] }
  },
  "mounts": [
    "source=devcontainer-go-modcache,target=/go/pkg/mod,type=volume"
  ],
  "postCreateCommand": "go mod download",
  "remoteUser": "vscode"
}

Air-gapped setups need an internal proxy — see configuring gopls module proxy in an air-gapped container — and building for other targets is covered in cross-compiling Go binaries inside containers.

The postCreateCommand: go mod download is a deliberate warming step, and understanding its scope clarifies what the caches do. go mod download fetches every module in the dependency graph into GOMODCACHE without building anything, so the module sources are present before your first build. Run at container create time, it overlaps with other setup and front-loads the network cost, so your first go build does not also pay for downloading dependencies. Paired with the GOMODCACHE volume, this means the download happens once on first create and is reused on every rebuild — the modules persist on the volume, and go mod download on a subsequent rebuild is near-instant because everything it would fetch is already there.

The two environment variables in the config work together to make the environment both safe and correct, and each addresses a distinct concern. GOFLAGS=-mod=readonly makes builds fail rather than silently edit the module files, protecting the reproducibility of the dependency graph. GOMODCACHE=/go/pkg/mod names the cache location explicitly so the volume mount and the toolchain agree on where modules live — a mismatch here, where the volume mounts one path and Go writes to another, is the silent-cache-failure mode that afflicts any misdirected cache. Setting both in containerEnv means they apply to every process in the container — the terminal, gopls, and any script — so the whole environment shares one consistent, safe module configuration rather than relying on each tool to set it.

A per-user ownership detail matters here as it does for every cache volume: the GOMODCACHE volume must be writable by the remoteUser. If the volume is created root-owned while the container runs as an unprivileged user, go mod download cannot populate it and fails with permission errors that look like network or tooling problems. Ensuring the module cache (and GOCACHE, if you volume-mount it too) is owned by the remoteUser — via a postCreateCommand chown or appropriate mount configuration — is what lets the cache work under the non-root discipline the security guides recommend. Anticipating this alongside adding the volume saves a confusing round of "the cache is mounted but the build still fails."

Performance & Resource Optimization

Cold go mod download is the slow step; a warm GOMODCACHE on a named volume makes it near-instant on rebuilds. For fully offline builds, vendoring (go mod vendor) removes the network entirely at the cost of a larger repo.

Module fetch timeA warm GOMODCACHE or vendored modules cut dependency fetch time dramatically.Cold go mod download62sWarm module cache7sVendored modules4sillustrative fetch time

Cache the build cache (GOCACHE) as well as the module cache so incremental builds stay fast, and cache gopls's own index so the editor is responsive immediately after attach. For air-gapped environments, point GOPROXY at an internal module proxy so go mod download resolves without reaching the public proxy — the offline pattern in the linked air-gapped guide.

Go actually has two caches worth persisting, and conflating them leaves speed on the table. GOMODCACHE holds downloaded module sources — it speeds up dependency fetching. GOCACHE holds compiled build artifacts — it speeds up incremental compilation, so unchanged packages are not recompiled. They are distinct stores keyed on different things (module versions versus compilation inputs) and change on different schedules, so caching only GOMODCACHE still leaves every rebuild recompiling from scratch. Mounting both on named volumes is what makes Go rebuilds fully fast: the modules are already downloaded and the unchanged packages are already compiled. This mirrors Rust's registry-plus-target split — one cache for fetched sources, one for compiled output — and the payoff is the same, a rebuild that reuses both rather than redoing either.

The air-gapped and offline cases are where Go's proxy model becomes a performance-and-connectivity concern rather than just a speed one. By default go mod download reaches the public module proxy, which is unavailable in an air-gapped environment and slow behind a poor connection. Two strategies address this: pointing GOPROXY at an internal proxy that mirrors the modules you need, so resolution stays inside your network; or vendoring with go mod vendor, which copies dependencies into the repository so the build reads them locally and never touches the network at all. Vendoring trades a larger repository for complete network independence, which is often the right call for teams that must build offline. The linked air-gapped guide covers the proxy configuration and the checksum considerations (GONOSUMDB and friends) that come with an internal mirror.

Validation & Testing

Confirm gopls resolves against the container Go (its symbols match go build) and that go.sum verifies unchanged. Building the whole module tree is the definitive check.

Go validationConfirm gopls uses the container toolchain and go.sum verifies unchanged.Does gopls resolve against the containerGo?NOGOPATH/toolchain mismatchDoes go.sum verify unchanged?YESModule cache on a volumeEditor and build agree

# gopls and the compiler must agree; checksums must verify
go env GOROOT GOMODCACHE          # both inside the container
go build ./... && go vet ./...    # compiles against the cached modules
go mod verify                     # go.sum intact

The go mod verify check is the validation that proves your module cache and checksums are intact, and it belongs in CI. It confirms that the modules in the cache match the checksums recorded in go.sum, so a corrupted or tampered cache is caught rather than silently used. Combined with go build ./... (which compiles the whole module tree against the cached modules) and go vet ./... (which catches suspicious constructs), it forms a validation triple that exercises the environment end to end: the toolchain builds, the modules verify, and the code passes basic checks. Because all three run headlessly, they belong in the CI pipeline where they gate every change, confirming that the same environment developers use produces a clean, verified build.

The gopls-agreement validation is worth doing deliberately because a mismatch is confusing rather than obviously broken. Check go env GOROOT GOMODCACHE inside the container to confirm the toolchain and module cache are the container's, then confirm gopls reports the same and shows the same errors go build produces on a file with a deliberate mistake. If gopls flags an import the compiler resolves fine, or misses an error the compiler catches, it is resolving against a different toolchain or module set, and the fix is to ensure GOROOT, GOPATH, and GOMODCACHE are all the container's. Validating this agreement once saves hours of second-guessing editor diagnostics that do not match what will actually compile.

Common Pitfalls

The failures below are gopls routing or an uncached/unreachable module store. The triage below sorts them.

Go pitfall triageA triage path from gopls mismatch or slow fetches to a deterministic Go env.Does gopls report different symbols thango build?YESRoute gopls at container GoDo modules re-download each rebuild?YESMount GOMODCACHE on a volumeDeterministic Go env

SymptomRoot CauseRemediation
Editor symbols disagree with go buildgopls routed at the wrong toolchainKeep GOROOT/GOPATH/GOMODCACHE in the container
Modules re-download every rebuildNo volume on GOMODCACHEMount /go/pkg/mod on a named volume
Build edits go.mod unexpectedlyMissing -mod=readonlySet GOFLAGS=-mod=readonly
go mod download fails offlinePublic proxy unreachablePoint GOPROXY at an internal proxy or vendor
Slow incremental buildsBuild cache not persistedCache GOCACHE on a volume too

The gopls-routing pitfall is the one that most often makes a Go devcontainer feel subtly wrong, and it is worth expanding because the symptom points at the editor when the cause is the environment. A gopls resolving a host GOPATH that leaked in — or a different toolchain — indexes modules the compiler does not use, so it reports imports as unresolved, offers stale completions, or disagrees with go build about types. Developers conclude the editor tooling is buggy and start ignoring its diagnostics, which defeats the point of having a language server. The fix is to ensure GOROOT, GOPATH, and GOMODCACHE are all the container's, so gopls and the compiler read identical inputs. The tell is that the editor and go build disagree on the same file — a disagreement that should be impossible when both use the same toolchain and modules, and whose existence therefore points straight at a routing mismatch.

The go.mod-mutation pitfall is the quieter reproducibility hazard, and it is exactly what -mod=readonly exists to prevent. Without it, a build that encounters a missing or under-specified dependency silently edits go.mod and go.sum to make itself succeed, so the dependency graph changes as a side effect of building and two developers can drift apart without noticing. The symptom is an unexpectedly modified go.mod after a build, or CI failing on a graph that differs from what a developer committed. Setting GOFLAGS=-mod=readonly makes the build fail loudly instead, forcing the module files to be explicit and committed. Both pitfalls share this section's recurring lesson: an environment is only reproducible and trustworthy when the tools are pinned to the same inputs and forbidden from silently changing them, and Go gives you the levers — readonly mode and consistent paths — to enforce exactly that.

Conclusion

Pin the toolchain and checksums, cache the module and build caches, and keep gopls routed at the container's Go so the editor and the compiler always agree. Go's go.sum gives you determinism for free; your job is to make it fast with cached stores and, for air-gapped teams, an internal proxy or vendored modules. Hold those and every developer builds the identical binary from the identical source.

Pin and cache GoPin the toolchain and checksums; cache the module and build caches for fast rebuilds.PinGo versiongo.sumGOFLAGS readonlyCacheGOMODCACHEbuild cachegopls index

Stepping back, the Go environment is a clean example of this section's central division: go.sum handles reproducibility completely, so the devcontainer work is entirely about speed and editor fidelity. The cryptographic checksums pin every dependency, and -mod=readonly prevents the build from changing them, so every developer and CI run compile the identical dependency graph with no further effort. That frees the setup to focus on the two things that actually vary in practice — how fast rebuilds are, and whether the editor agrees with the compiler. Cache GOMODCACHE and GOCACHE for speed, route gopls at the container toolchain for fidelity, and set the readonly flag to keep the graph honest, and you have a Go environment that is fast, identical everywhere, and trustworthy in the editor.

The robustness of this setup comes from every defining input being explicit and enforced. The toolchain is pinned in the Feature (and, watching the toolchain directive, in go.mod); the dependency graph is pinned in go.sum and enforced by -mod=readonly; the caches only ever hold checksum-verified modules and content-keyed build artifacts. Nothing about the environment can drift silently, because a dependency change requires a reviewed go.mod/go.sum edit and a toolchain change requires a reviewed Feature bump. The result is the same guarantee the whole section aims for — pin what must stay constant, cache what is expensive to reproduce — applied to a language whose module system already does most of the pinning for you, leaving speed and editor honesty as the work that remains.

FAQ

Why do gopls and go build disagree about my code? Because gopls is resolving against a different toolchain or module cache than the compiler — typically a host GOPATH leaking in. Keep GOROOT, GOPATH, and GOMODCACHE all inside the container so gopls indexes exactly the modules go build compiles, and their views converge. A disagreement on the same file is the tell: it should be impossible when both use identical inputs, so its existence points straight at a routing mismatch rather than a real code problem, and resolving the paths makes the editor a faithful preview of the compiler again.

How do I make Go builds work in an air-gapped container? Point GOPROXY at an internal module proxy that mirrors the modules you need, or vendor your dependencies with go mod vendor so the build reads them from the repo and never touches the network. The air-gapped guide covers configuring the proxy and the GONOSUMDB/checksum considerations that come with it. The trade-off between the two is repository size versus infrastructure: an internal proxy keeps the repo small but requires you to run and maintain the proxy, while vendoring needs no infrastructure but enlarges the repo and adds vendored-directory churn to diffs. For strictly offline builds vendoring is often simplest, since it removes the network dependency entirely; for a team with an internal network but no public-proxy access, the mirror is cleaner.

What's the fastest way to speed up module fetches? Mount GOMODCACHE (/go/pkg/mod) on a named volume so downloaded modules persist across rebuilds, and cache GOCACHE too for fast incremental compilation. Because go.sum pins checksums, a warm cache is always safe — it can only contain the exact modules your checksums allow. Remember these are two distinct caches: GOMODCACHE speeds up fetching dependency sources, while GOCACHE speeds up recompiling unchanged packages. Persisting only one leaves the other cost paid on every rebuild, so mount both volumes to get Go rebuilds that reuse both the downloads and the compiled output.

Should I vendor my Go dependencies or rely on the module cache? It depends on your connectivity and reproducibility needs. The module cache (a GOMODCACHE volume) is simpler and keeps the repository small, and it is the right default for teams with reliable network access to the module proxy. Vendoring (go mod vendor) copies dependencies into the repository so builds read them locally and never touch the network — essential for air-gapped environments and a strong guarantee against a dependency disappearing upstream, at the cost of a larger repository and vendored-directory churn in diffs. Many teams use the cache for everyday development and reserve vendoring for offline or high-assurance builds; both are safe because go.sum verifies the modules either way.

Does gopls need its own cache, or does GOMODCACHE cover it? gopls builds its own index of your code and dependencies for fast symbol lookup, and while it reads from GOMODCACHE, its index is a separate artifact. On a large codebase, gopls can take a noticeable moment to build that index after attach. You cannot easily persist gopls's internal index across rebuilds the way you persist GOMODCACHE, but keeping GOMODCACHE and GOCACHE warm means gopls has less work to do when it does index, since the modules and build artifacts it references are already present. For most projects the index builds quickly enough that this is a non-issue; it only becomes noticeable on very large module trees, where the warm module and build caches at least ensure gopls is indexing against artifacts that are already present rather than fetching or compiling them as it goes.