Configuring gopls Module Proxy in an Air-Gapped Container
In an air-gapped environment the public Go module proxy is unreachable, so go mod download and gopls fail. This page routes module resolution through an internal proxy with GOPROXY, or vendors dependencies so builds read them from the repo — either way, Go works offline without reaching the internet.
This matters because Go's toolchain treats network module resolution as a normal part of a build, not an optional convenience. The compiler, go mod download, go test, and gopls all consult GOPROXY whenever a module in go.mod is not already present in the local cache, and by default that value is https://proxy.golang.org. Inside a container with no route to the public internet, every one of those operations blocks and then fails with a dial timeout or a DNS error, and the failure is often mistaken for a broken go.mod rather than a missing network path. Understanding that a single environment variable governs where modules come from is the whole mental model: you are not disabling module resolution, you are redirecting it to a source that lives inside the perimeter.
Reach for this configuration when your devcontainer runs in a locked-down network — a regulated CI runner, a classified build host, or any environment where egress is denied by policy rather than by accident. The decision splits two ways. If your organization already runs an internal proxy such as Athens or an Artifactory Go remote, point GOPROXY at it and let the proxy mirror upstream modules on its own controlled schedule. If you want builds to depend on nothing beyond the checked-out repository, vendor the dependencies into a committed vendor/ directory. Both give you the same reproducibility Go promises online; they differ only in where the bytes physically live and who keeps them fresh.
Prerequisites
You need either an internal module proxy or vendored dependencies.
- An internal Go module proxy (Athens, JFrog, etc.) reachable from the container, or
- Vendored dependencies committed with
go mod vendor. GOPROXY/GOFLAGSset appropriately.
Before you touch any configuration, confirm which of the two paths your environment can actually support, because they have different operational owners. An internal proxy is a shared service: someone has to run it, keep it reachable from the container's network namespace, and pre-warm it with the modules your go.mod references, since an air-gapped proxy that has never seen a given version cannot fetch it on demand. Vendoring, by contrast, moves that responsibility into the repository — the modules travel with the code, so the only prerequisite is that go mod vendor was run on a machine that did have network access, and the resulting directory was committed.
The detail people most often get wrong is the checksum database. Even with GOPROXY pointed at a reachable internal mirror, Go still tries to verify downloaded modules against sum.golang.org by default, and that host is just as unreachable as the public proxy. A build can therefore fail at the verification step long after resolution succeeds, which looks baffling if you assumed the proxy setting covered everything. Plan to set GONOSUMCHECK/GONOSUMDB or GONOSUMDB=off alongside GOPROXY, or rely on the vendor/ path where checksums are validated once at vendor time and recorded in go.sum.
Step-by-Step Implementation
- Point GOPROXY at an internal proxy so resolution never hits the internet.
{
"features": { "ghcr.io/devcontainers/features/go:1": { "version": "1.22" } },
"containerEnv": {
"GOPROXY": "https://goproxy.internal.example.com",
"GONOSUMCHECK": "off",
"GOFLAGS": "-mod=readonly"
},
"remoteUser": "vscode"
}
Setting GOPROXY under containerEnv bakes the value into the container's environment so every process — the go binary, go test, and gopls launched by the editor — inherits the same resolution target without anyone remembering to export it in a shell. Pointing it at https://goproxy.internal.example.com means a go mod download never resolves the public proxy.golang.org, which is what removes the dial timeout entirely. The GONOSUMCHECK flag disables the online sum-database lookup that would otherwise fail against the unreachable sum.golang.org, and -mod=readonly in GOFLAGS forbids the toolchain from silently editing go.mod when it discovers a missing requirement, so a build that needs a module the proxy has not mirrored fails loudly instead of rewriting your manifest. That readonly discipline is the failure mode this step prevents: an accidental network-dependent go.mod edit that would have quietly assumed egress you do not have.
- Or vendor dependencies so builds read them from the repo.
go mod vendor # commit the vendor/ directory
Running go mod vendor copies every module listed in go.mod — plus the transitive dependencies actually imported by your packages — into a top-level vendor/ directory, and writes a vendor/modules.txt manifest that records exactly which versions were captured. You run this once on a machine with network access, then commit the result, so the modules become part of the repository's own history rather than something fetched at build time. The reason to prefer this over the proxy in the strictest environments is that it eliminates the network from the build entirely: there is no host to reach, no DNS to resolve, and no proxy to keep pre-warmed. The failure it prevents is the "proxy has never seen this version" gap, because the bytes are already on disk the moment the repository is cloned into the container. when vendoring.
{ "containerEnv": { "GOFLAGS": "-mod=vendor" } }
Setting GOFLAGS to -mod=vendor forces every go invocation to read packages from vendor/ and to ignore both the module cache and GOPROXY completely. Modern Go versions will use the vendor directory automatically when vendor/modules.txt is consistent with go.mod, but making the flag explicit removes any ambiguity — it guarantees that a build cannot fall back to a network fetch if the directory looks stale or if a stray environment override slips in. Placing it in containerEnv means gopls sees the same mode as the compiler, so the editor resolves against the committed vendor/ tree too. Without this flag, the classic symptom is a build that appears to succeed by silently downloading a module the vendor directory was supposed to supply, which defeats the whole point of vendoring in a locked-down environment.
- Verify a build succeeds with the network disabled.
go build ./... && echo "offline build OK"
The verification step matters more than it looks, because the only honest test of an air-gapped configuration is a build run with the network genuinely severed — not merely a build that happened not to need a fetch. Compiling ./... walks every package in the module, so it exercises the full dependency graph rather than the single package you were editing, and the && echo gate prints its confirmation only if the compile returned zero. Run this with the network disconnected, or with GOPROXY=off temporarily set, to prove that no step is quietly reaching upstream. If the build fails here after you thought resolution was configured, the error text almost always names the specific module that the proxy never mirrored or that vendoring missed, which turns an abstract "it doesn't work offline" into a concrete, fixable gap.
Common Pitfalls
Air-gapped Go issues are an unreachable proxy or checksum verification against the public sum DB.
A pitfall that is easy to miss involves ownership of the module cache when you combine the proxy path with a named volume or a bind mount. Go writes downloaded modules into $GOPATH/pkg/mod as read-only files, and if the container's remoteUser — vscode in the configuration above — does not own that directory, the very first go mod download fails with permission-denied errors that look nothing like a network problem. When the cache lives on a persistent volume shared across rebuilds, the files retain the UID that first created them, so a later container running as a different user cannot write new modules or clean stale ones. Fix it by ensuring the cache path is owned by the same user the container runs as.
The other trap is forgetting that gopls is a separate process from your build. It is common to set GOPROXY or -mod=vendor in a task or a Makefile and see go build pass, while the editor continues to show red squiggles on every import because the language server was launched with the ambient environment, not your build's. gopls resolves modules through the same toolchain, so it needs the same GOPROXY, GOFLAGS, and sum-database settings visible in its own environment. Setting them at the container level — in containerEnv rather than in a shell profile or a single build script — is what guarantees the compiler and the language server agree, and it is why the steps above put the values there.
| Symptom | Root Cause | Remediation |
|---|---|---|
| go mod download fails offline | Public proxy unreachable | Set GOPROXY to an internal proxy |
| Checksum verification fails | Public sum DB unreachable | Configure GONOSUMDB or vendor + verify |
| gopls shows unresolved imports | Language server can't fetch modules | Ensure GOPROXY/vendor is set for gopls too |
| Vendored build ignores vendor/ | Wrong mod mode | Set GOFLAGS=-mod=vendor |
Conclusion
Give Go a source it can reach offline: an internal GOPROXY that mirrors your modules, or a committed vendor/ directory the build reads directly. Either keeps go mod download and gopls working with no internet — the same determinism as online, just sourced locally.
The strategic payoff is that an air-gapped configuration is really just the strictest expression of the pin-and-cache discipline that makes any devcontainer reproducible. When you pin the Go feature to 1.22 and pin every dependency version in go.sum, you have already decided that a build should produce the same result regardless of when or where it runs; pointing GOPROXY at an internal mirror or committing vendor/ simply removes the last variable — the public internet — that could change that result out from under you. A module deleted upstream, a proxy rate limit, or a transient DNS failure can no longer break a build that never depends on reaching those hosts in the first place.
That is why this technique pays off well beyond the classified-network case that forces it. Teams that vendor or run an internal proxy get faster, more predictable builds even with the network available, because resolution stays inside the perimeter and no longer waits on an external round trip. The same containerEnv block that satisfies an auditor also protects an ordinary CI pipeline from upstream flakiness. Configuring the module proxy for the air-gapped case, in other words, hardens the whole supply chain, not just the disconnected corner of it.
FAQ
How do Go builds work with no internet?
Either point GOPROXY at an internal module proxy (Athens, Artifactory, etc.) that mirrors the modules you need, so resolution stays inside your network, or vendor dependencies with go mod vendor and build with -mod=vendor so the build reads them from the committed vendor/ directory and never touches the network. The choice comes down to who owns freshness: a proxy is a shared service someone pre-warms and maintains, while a vendor/ directory travels with the repository and needs no infrastructure at all. In both cases go.sum still pins exact checksums, so the offline build is byte-for-byte the same as the online one — you have only changed where the bytes come from, not what they are.
Does gopls need the proxy configured too?
Yes. gopls resolves modules the same way the compiler does, so it must see the same GOPROXY (or vendored modules). Set the environment at the container level so both the language server and go build share it, otherwise the editor shows unresolved imports that build fine. The mismatch happens because the editor launches gopls with the container's ambient environment, so any variable you exported only inside a build script or terminal session is invisible to it. Putting GOPROXY and GOFLAGS in containerEnv is the reliable fix, because that environment is present the moment the container starts, before either the compiler or gopls runs.
What about checksum verification offline?
The public checksum database is unreachable air-gapped, so configure GONOSUMDB/GONOSUMCHECK or rely on vendored modules whose integrity you verify at vendor time. Your go.sum still pins checksums locally, so determinism holds; you're only bypassing the online sum-DB lookup. It helps to separate the two mechanisms Go conflates here: go.sum is a local integrity file that guarantees a module's bytes match what you originally recorded, while sum.golang.org is a global transparency log that cross-checks those hashes against the wider ecosystem. Air-gapping breaks only the second, so disabling the online lookup does not weaken the local guarantee that go.sum already enforces on every build.
Should I use GONOSUMCHECK or GONOSUMDB, and can I scope it?
Prefer GONOSUMDB or the modern GOSUMDB=off to switch off only the transparency-log lookup, and use GONOSUMCHECK sparingly since it is a broader override. If some of your modules come from an internal host that should never be checked but you still want verification for others, set GONOSUMDB (or GOPRIVATE) to a comma-separated list of module path prefixes rather than turning verification off wholesale. Scoping it this way keeps checksum protection for public modules mirrored through your proxy while exempting the internal ones that the public database has never seen.
Can I mirror only the modules my project needs instead of everything?
Yes, and for an air-gapped proxy that is usually the point. A proxy like Athens caches a module the first time it is requested from a machine that still has upstream access, so the practical workflow is to run go mod download against the proxy once from a connected host to pre-warm it with exactly the versions in your go.sum. After that, the disconnected container asks for those same versions and gets cache hits. The vendor/ approach captures the same closure automatically, since go mod vendor copies only the modules your packages actually import.
Related
- Up to Go Development Environment with gopls & Modules — the parent guide on Go setup.
- Caching Go Module Downloads with a Named Volume — speeding resolution when online.
- Cross-Compiling Go Binaries Inside Containers — building targets from the same modules.