Why does go build try to download local Go modules from GitLab in GitLab CI after moving to a monorepo, but works fine locally?
We have a Go project originally split across three GitLab repositories (backend, ui, and library) hosted at gitlab.com/mycompany. These built successfully using GitLab CI with a custom Docker image.
Now, we’ve consolidated everything into a single monorepo at gitlab.com/mycompany/everything, with subdirectories everything/backend, everything/ui, and everything/components containing the same code (with path tweaks). The .gitlab-ci.yml scripts work except for the Go build in backend, which fails because it attempts to download our own internal packages from GitLab—something it never did before.
Error Example
go: finding module for package gitlab.com/mycompany/backend/modules/sigproc/bandvaluerangegen
go: finding module for package gitlab.com/mycompany/backend/modules/sigproc/bandnoiseestimator
go: gitlab.com/mycompany/backend/chaining/builder imports
gitlab.com/mycompany/backend/modules/sigproc/bandnoiseestimator: no matching versions for query "latest"
go: gitlab.com/mycompany/everything/chaining/builder imports
gitlab.com/mycompany/everything/modules/sigproc/bandvaluerangegen: no matching versions for query "latest"
Local builds (without GitLab SSH keys) succeed without downloading these packages.
What We’ve Tried
- Added to build step:
- git config --global url."https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/".insteadOf https://gitlab.com/
- go env -w GOPRIVATE=gitlab.com/${CI_PROJECT_NAMESPACE}
This reaches GitLab but fails to find packages and targets the main branch (not desired).
- Moved
go.modfromeverything/backendtoeverythingroot and updated imports fromgitlab.com/mycompany/backend/...togitlab.com/mycompany/everything/backend/.... No change.
How can we prevent go build from trying to resolve and download these internal modules via GitLab in CI, while keeping the deeply nested package structure? Ideal for GitLab CI monorepo with private Go modules.
Because Go resolves imports by module path, the go tool in CI will try to fetch any module it can’t resolve locally — so after you moved to a monorepo, CI doesn’t see the sibling modules as local and falls back to downloading from GitLab. Locally you probably had a workspace/replace or a different working tree that made the imports resolve; in GitLab CI you need to explicitly tell Go where the local modules are (go.work or replace), or provide vendored code / proper credentials (GOPRIVATE + netrc) so builds don’t attempt remote fetches. Use a committed go.work (or replace directives), set GOPRIVATE and a PAT in CI, or vendor modules to stop go build from hitting GitLab.
Contents
- Why this happens: local vs CI module resolution
- Root causes to check quickly
- Fixes (recommended order): go.work, replace, vendoring
- CI recipe: example .gitlab-ci.yml for a monorepo
- Credentials, GOPRIVATE and CI token gotchas
- Debugging checklist and commands to run in CI
- Sources
- Conclusion
Why go build tries to download local go modules in GitLab CI monorepo
When you import packages like gitlab.com/mycompany/backend/modules/…, the go tool maps that import path to a module. If the go command can’t find a local module providing that module path, it queries the module proxy or the VCS (GitLab). Locally you often have one of these that hides the problem:
- a committed go.work or local replace rules,
- an unchecked local checkout (your editor/IDE builds in a workspace),
- GOPRIVATE/GOPROXY settings you configured on your machine,
- or simply a full working tree where go finds the matching go.mod.
In CI, none of those are guaranteed. If go doesn’t see a go.work pointing at ./backend (or a replace that maps gitlab.com/mycompany/backend → ./backend), it treats the package as remote and tries to fetch it from gitlab.com. The official Go modules reference explains how replace and workspaces change the module graph and let the go tool use local code instead of remote copies — that’s the mechanism you want to use in a monorepo Go modules reference.
Two common symptoms you’ve seen:
- Mixed import/module paths (old repo path + new everything path) — leads to confusion in the resolver.
- “no matching versions for query ‘latest’” — go tried to fetch the package and couldn’t find a version (or the CI clone had no tags / was shallow).
Root causes to check quickly
- Module path vs file layout mismatch: the module declaration in a go.mod must match the import path used by your code. Search the tree for inconsistent imports:
grep -R "gitlab.com/mycompany/backend" -n .- No go.work / no replace mapping in CI: a local go.work (or replace) that exists on your laptop but wasn’t committed will make local builds succeed and CI fail.
- CI checkout shallow or missing tags:
no matching versions for query "latest"often means the module was requested by version but tags/refs aren’t available in the CI clone. - CI credentials and GOPRIVATE: without GOPRIVATE and valid credentials, fetching private modules from gitlab.com will fail or be blocked by proxy/checksum DB behavior.
- Use of CI_JOB_TOKEN: works sometimes but has permission/behavior limits (it can cause the go tool to resolve to the repo’s default branch or otherwise not find versions).
If you moved go.mod to the repository root, that alone won’t help unless the module paths and imports line up or you also introduce a workspace/replace to tell Go which local folders provide which module paths.
Fixes (recommended order): go.work, replace, vendoring
- Prefer go.work (Go 1.18+) — simplest for monorepos
- Create and commit a go.work in the monorepo root that lists the module directories. When present, the go tool will treat those directories as providing the modules and won’t fetch them remotely.
Commands:
# at repository root
go work init ./backend ./components ./ui
git add go.work && git commit -m "Add go.work for monorepo workspace"
Example go.work (committed):
go 1.20 use ( ./backend ./components ./ui ) replace gitlab.com/mycompany/backend => ./backend replace gitlab.com/mycompany/components => ./components
Why this works: the workspace tells the go tool “these directories are local modules that satisfy imports,” so go build stops querying GitLab. See the official docs for work/replace behavior: https://go.dev/ref/mod.
- If you can’t use go.work: add replace directives
- In the module that is being built (or in a central go.mod), add:
replace gitlab.com/mycompany/backend => ../backend
- This points the module path to a local relative directory. It’s manual but effective.
- Vendoring (offline, deterministic)
- Run
go mod vendorfor the module and in CI build with vendor mode:
cd backend
go mod vendor
go build -mod=vendor ./...
- Pros: no network, reproducible. Cons: requires updating vendor on changes and increases repo size.
- Ensure module declarations and imports match
- If your code imports gitlab.com/mycompany/backend/… but the go.mod in ./backend declares
module gitlab.com/mycompany/everything/backend, that mismatch will make go try to fetch. Fix either imports or module declarations (or use replace/go.work).
- Avoid relying on CI_JOB_TOKEN if possible
- It can be okay for git fetch replacement, but it sometimes causes version queries to resolve to the default branch or lacks API scope. Prefer a read-only PAT stored as a masked GitLab variable for module fetches when you need network access.
CI recipe: example .gitlab-ci.yml for a monorepo
Below is a practical job that assumes you commit a go.work at repo root. It sets GOPRIVATE, forces a full fetch, and adds a safe netrc if you still need remote access.
variables:
GOPRIVATE: "gitlab.com/mycompany"
GIT_DEPTH: "0" # fetch full history (tags) if you need versions
stages:
- build
build-backend:
image: golang:1.20
stage: build
before_script:
# Preferred: use a Personal Access Token (read_repository) stored as $GITLAB_PAT
- 'echo -e "machine gitlab.com\nlogin gitlab-ci-token\npassword ${GITLAB_PAT}" > ~/.netrc'
- chmod 600 ~/.netrc
- go env -w GOPRIVATE=gitlab.com/mycompany
script:
- cd backend
- go test ./... # or go build ./...
Notes:
- Store GITLAB_PAT as a protected, masked CI/CD variable with read_repository scope. This is more reliable than CI_JOB_TOKEN for go module fetching.
- If you prefer trying CI_JOB_TOKEN first, keep the git config trick, but be aware of limitations:
git config --global url."https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/".insteadOf "https://gitlab.com/"- That may still make the go tool query remote versions (and pick branch tips) rather than using local code.
If you cannot commit go.work, you can create it on-the-fly in CI (not ideal but possible):
# at repo root in job
go work init ./backend ./components ./ui
cd backend
go build ./...
But committing go.work is better: less magic in CI.
Credentials, GOPRIVATE and CI token gotchas
- Set GOPRIVATE to your GitLab domain pattern so modules are fetched directly and excluded from the public checksum DB:
go env -w GOPRIVATE=gitlab.com/mycompany- When GOPRIVATE is set, the go tool will attempt direct VCS fetches — so you must supply credentials for git operations. Use ~/.netrc with a PAT:
echo -e "machine gitlab.com\nlogin <gitlab-username>\npassword ${GITLAB_PAT}" > ~/.netrc
chmod 600 ~/.netrc
- CI_JOB_TOKEN vs PAT:
- CI_JOB_TOKEN is convenient, but it doesn’t always have the permissions the go tool needs, and some workflows cause resolution to target default branch or fail to find versions. For reliable module access, use a PAT with appropriate scope (read_repository).
- If you combine GOPRIVATE with a missing local replacement, go will still query the repository (direct fetch), so use go.work / replace to avoid network calls entirely.
Practical reference on PAT/netrc and GOPRIVATE patterns: https://dev.to/mariocarrion/configuring-gitlab-ci-and-private-go-modules-46h7 and community Q&A such as https://stackoverflow.com/questions/29707689/how-to-use-go-with-a-private-gitlab-repo.
Debugging checklist and commands to run in CI
Run these in the CI job (or locally) to see what Go sees:
-
Which go.mod/work files are being used:
-
go env GOMOD GOWORK -
cat $(go env GOMOD)(shows the go.mod actually in effect) -
cat go.work(if present) -
See environment relevant to modules:
-
go env GOPRIVATE GOPROXY GOSUMDB GOMODCACHE -
See module graph and where it resolves modules:
-
go list -m all -
go list -m -json all(shows Replace fields and versions) -
Check for leftover imports pointing to old paths:
-
grep -R "gitlab.com/mycompany/backend" -n . -
Confirm git refs/tags exist (if you rely on versions):
-
git ls-remote origin 'refs/tags/*'or ensureGIT_DEPTH: "0"in job config.
Interpreting the “no matching versions for query ‘latest’” error:
- It usually means the go tool attempted to fetch a module (via HTTP/Git), looked for tags/versions and couldn’t find an acceptable version (or the CI clone had no tags). If you don’t want to publish separate versions for submodules, use go.work/replace or vendor to avoid version resolution entirely.
Sources
- https://go.dev/ref/mod
- https://docs.gitlab.com/ee/user/packages/workflows/working_with_monorepos.html
- https://about.gitlab.com/blog/building-a-gitlab-ci-cd-pipeline-for-a-monorepo-the-easy-way/
- https://dev.to/mariocarrion/configuring-gitlab-ci-and-private-go-modules-46h7
- https://stackoverflow.com/questions/57182988/gitlab-ci-and-go-modules
- https://vyskocil.org/blog/go-111-modules-monorepo-and-shared-code/
- https://stackoverflow.com/questions/29707689/how-to-use-go-with-a-private-gitlab-repo
- https://medium.com/@manoj.dec22/import-private-go-modules-from-gitlab-71665daa9f9
- https://earthly.dev/blog/golang-monorepo/
- https://github.com/golang/go/issues/51779
Conclusion
In a monorepo, the go tool needs an explicit mapping from module import path → local directory. Locally you probably had that mapping implicitly; GitLab CI didn’t, so go build fell back to downloading from GitLab. The clean, future-proof fixes are: commit a root go.work that usees the submodules (best), add replace directives where needed, or vendor the modules; set GOPRIVATE and provide a PAT in CI only when you must allow remote fetches. Do that and your GitLab CI job will stop trying to pull your internal packages from gitlab.com.