Your Go Import Path Is a Public API. Stop Renting It From a Git Host
Every import line in a Go repository is a public API, and most teams host that API on someone else's domain. module github.com/acme/billing in go.mod is not a l...
Every import line in a Go repository is a public API, and most teams host that API on someone else's domain. module github.com/acme/billing in go.mod is not a location hint. It is the permanent identity of the package for every consumer, every go.sum entry, and every module proxy cache keyed on that string.
Move the repository and you face exactly two options: rewrite every import in every downstream module, or keep paying the old host forever. Iain Cambridge describes a company running GitLab, GitHub, and Azure DevOps at the same time because relocating the code was "too big a task" — three hosting contracts instead of one DNS record.
The fix is older than the complaint. go.uber.org, go.mongodb.org, and golang.org/x/* never pointed directly at a git host. The go command already supports a dispatch mechanism that decouples the module path from the repository root; almost nobody outside large organizations uses it.
go get never trusted your host in the first place
When the go command resolves a module path, it does not parse your feelings about GitHub. It parses a string. A handful of hosts — github.com, gitlab.com, bitbucket.org — are hard-coded in the toolchain with their VCS URL patterns. Anything else triggers an HTTP lookup.
For path go.acme.dev/billing the toolchain issues GET https://go.acme.dev/billing?go-get=1 and scans the HTML for a single meta tag whose content is three whitespace-separated fields: prefix vcs repo-root.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="go-import" content="go.acme.dev/billing git https://git.acme.dev/platform/billing.git">
</head>
<body>Acme internal Go modules</body>
</html>
That tag is the entire indirection layer. The module path is the contract printed in consumer go.mod files and compiled into import statements. The repo-root field is an implementation detail only the resolver reads, and it can change without any consumer noticing.
You can inspect exactly what the toolchain sees:
$ curl -s 'https://go.acme.dev/billing?go-get=1' | grep go-import
<meta name="go-import" content="go.acme.dev/billing git https://git.acme.dev/platform/billing.git">
$ GOPROXY=direct go list -m go.acme.dev/billing@latest
go.acme.dev/billing v1.4.2
If the second command prints a version, your namespace is live and the git host behind it is now swappable. If it hangs or returns 404 Not Found, the meta tag is missing, the prefix does not match the requested path, or the go-get=1 query is not reaching your handler.
Three details of the protocol bite in production:
- The
prefixfield must be a prefix of the requested path. Servinggo.acme.dev/billingdoes not resolvego.acme.dev/billing/storeunless the tag is also reachable from that path — the go command walks up the path looking for a matching module root, and a tag whose prefix does not match is ignored. - GitHub is a hard-coded special case in
cmd/go/internal/get/vcs.go. It answers?go-get=1for every repository yet serves no tag for subdirectories, and the toolchain tolerates it anyway. Copy GitHub's behavior for your own host and subdirectory modules will fail withunrecognized import path. - Go 1.25 extended the tag with a fourth field —
<prefix> <vcs> <repo-url> [subdirectory]— so a monorepo can mapgo.acme.dev/platform/billingonto a subdirectory of one repository. Older toolchains ignore the extra field and resolve the module path as the repo root instead, which silently fetches the wrong tree. Only adopt it once your consumers are all on 1.25+.
Where the coupling sends you an invoice
The import path tax shows up in three places, and only the first one is obvious.
The migration you cannot schedule
Changing a module path is an identity change, not a URL change. Every consumer must edit go.mod, rewrite import lines, and re-run go mod tidy. For a library with a /v2 suffix the path change forces a new major version, so every consumer's upgrade becomes a breaking change even though zero code semantics moved.
Teams that discover this late make the rational short-term call: never migrate. The coupling compounds until operating three git hosts is cheaper than one import rewrite.
CI, release scripts, and the docs
Source code is the easy half. The hard half is everything around it: gh release create in the release job, GITHUB_TOKEN in CI secrets, artifact upload via the GitHub API, and install instructions that read go install github.com/acme/billing@latest. None of those survive a host move.
Keep the vendor-specific part as thin glue. Build, test, and release logic belongs in a Makefile or plain scripts that any CI system can call, so swapping gh for a self-hosted release tool touches one script instead of every workflow file.
Gate the source half mechanically so regressions fail the build instead of code review:
#!/usr/bin/env bash
# ci/check-imports.sh — fail when first-party code hardcodes a git host
set -euo pipefail
prefix="go.acme.dev"
violations=$(grep -Rhn --include='*.go' -E '"(github|gitlab|bitbucket)\.com/acme/' . || true)
if [[ -n "$violations" ]]; then
echo "first-party imports must use ${prefix}:"
echo "$violations"
exit 1
fi
echo "portable: $(grep -Rh --include='*.go' -c "\"$prefix/" . | paste -sd+ | bc) first-party imports under $prefix"
Egress rules that forbid the host itself
Air-gapped or regulated environments commonly block outbound traffic to github.com while allowing your own domains. Code whose module path, proxy config, and release tooling all name the vendor cannot even be built there. Code that names go.acme.dev builds anywhere your DNS resolves.
Standing up the namespace takes an afternoon
You need a subdomain you control, a TLS certificate, and a handler that returns different bodies for the go command versus humans. DNS delegation and ACME certificates are routine; the handler is the only part with a sharp edge.
The sharp edge is the redirect condition. Humans following a link in a README should land on the browsable repository. The go command needs the meta tag. Distinguish them by the go-get=1 query parameter, and pick $uri over $request_uri when building the tag — $request_uri carries the query string, which corrupts the prefix field and breaks resolution for every module on the host.
# /etc/nginx/sites-available/go.acme.dev
server {
listen 443 ssl http2;
server_name go.acme.dev;
# ssl_certificate / ssl_certificate_key: ACME renewal unchanged
location / {
# No go-get=1 -> a human. Send them to the git host.
if ($args !~ "go-get=1") {
return 301 https://git.acme.dev$request_uri;
}
# The go command. $uri is the bare path; never $request_uri here.
add_header Content-Type "text/html; charset=utf-8";
return 200 '<!doctype html><meta charset="utf-8"><meta name="go-import" content="go.acme.dev$uri git https://git.acme.dev/platform$uri.git">';
}
}
Serve a static response — no application server, no database, no deploy pipeline for the namespace itself. This endpoint sits on the critical path of every go mod download that misses the proxy, so its failure mode should be "a file nginx already has in memory."
Then sweep the repo and publish:
$ go mod edit -module go.acme.dev/billing
$ grep -rl '"github.com/acme/billing' --include='*.go' . \
| xargs sed -i 's|github.com/acme/billing|go.acme.dev/billing|g'
$ go mod tidy
$ git commit -am 'namespace modules under go.acme.dev'
One migration, executed once, permanently removes the host from the module path. Everything downstream — proxies, mirrors, forks, vendored copies — now names a namespace you own.
Moving the repository without a consumer-side diff
Here is the payoff for teams already coupled to a vendor path: adopt the namespace first, move the host second. The order matters.
After the namespace exists, relocating from GitHub to a self-hosted Gitea or GitLab is a one-line change to the repo-root field in the meta tag. Consumers' go.mod files, their go.sum hashes, and their import statements are all keyed on the module path, which did not move. Zero downstream pull requests.
# Before: hosted on GitHub
return 200 '<!doctype html><meta name="go-import" content="go.acme.dev/billing git https://github.com/acme/billing.git">';
# After: self-hosted Gitea. Same prefix, different origin.
return 200 '<!doctype html><meta name="go-import" content="go.acme.dev/billing git https://git.acme.dev/acme/billing.git">';
Verify both sides before declaring victory: run GOPROXY=direct go list -m go.acme.dev/billing@latest against the new origin, then build a clean consumer module that has never cached the path.
$ mkdir /tmp/consumer && cd /tmp/consumer && go mod init example.com/scratch
$ GOPROXY=direct go get go.acme.dev/billing@latest
go: added go.acme.dev/billing v1.4.2
$ grep billing go.sum | head -1
go.acme.dev/billing v1.4.2 h1:9f3c...=
The module proxy layer is why this is safe. proxy.golang.org keys every cached zip by module path and version, and cached versions are immutable — after the origin flip, previously proxied releases keep resolving from the proxy while new tags get fetched from the new origin and checked against the same checksum database. Consumers see no error, no go.sum churn, and no forced upgrade window. Run go mod verify in a clean module cache afterwards; a mismatch there means the move altered tagged content, which is a repository-side bug, not a namespace bug.
If you skip the namespace and edit the module path directly instead, the cost is what the three-host company was avoiding: every consumer must follow, the major version suffix has to be re-evaluated, and any module proxy caches keyed on the old path go stale. The namespace-first sequence converts a fleet-wide migration into a DNS-level switch.
What the namespace actually costs you
The trade is not free, and pretending otherwise is how vanity domains get abandoned six months after launch.
- A new single point of failure. Before,
go getdepended on your git host. Now a cold fetch also depends on your DNS zone, your TLS certificate renewal, and whatever serves?go-get=1. A forgotten ACME renewal breaks module resolution for a host you forgot was on the critical path. - Proxy caching is the mitigating layer. The default
GOPROXYisproxy.golang.org, which caches the meta tag resolution per module path. Once a version is proxied, consumers keep building through an outage — but the first consumer to request a new version hits your endpoint directly if you setGOPROXY=director run an internal proxy chain. Size the DNS TTL and the certificate renewal loop accordingly. - Plain HTTP needs an escape hatch. The go command refuses
http://module sources unless you setGOINSECURE=go.acme.dev. You want HTTPS on the namespace anyway; note that the flag exists before someone patches it into a devgo.envand it never gets removed. - The checksum database re-keys on the path.
sum.golang.orgrecords are keyed by module path plus version, so a new namespace means fresh lookups on every consumer's first fetch of a given version. Internal modules usually bypass it withGOPRIVATE=go.acme.dev, which implies bothGONOPROXYandGONOSUMDBfor that pattern and forcesdirectfetches — an explicit decision to trade third-party verification for reachability, not an accident you want in a globalgo.env. - Maintenance surface. One nginx file and one DNS zone, but they are now production infrastructure for your build. Assign an owner. The company in the original story traded three hosting contracts for one namespace plus the discipline to keep it alive — that trade only pays if the namespace is boring and permanent.
There is also an argument against doing this for open-source libraries, and it is a good one: a vanity domain adds a personal-domain dependency to a package that would otherwise live and die with its repository. The cost-benefit flips with audience. A library others import deserves the namespace; a leaf binary nobody installs by path does not.
The failure mode you are actually preventing
Re-read the original incident: the company did not fail to update a git remote. They failed to keep a module path stable while three hosting bills accumulated, because rewriting import statements across dozens of repositories was projected to take longer than paying for GitLab, GitHub, and Azure DevOps in parallel.
That is the shape of every coupling failure worth naming. The source-level dependency is one string per file; the organizational dependency is a cross-team migration with a freeze window, a deprecation policy, and a consumer communication plan. Deciding the string early — go.acme.dev/... instead of github.com/acme/... — is the whole game, because the string is what every downstream go.mod freezes into its own go.sum.
Check your current exposure in one command:
$ grep -h '^module ' $(go env GOMODCACHE)/cache/download/*/*/@v/*.mod 2>/dev/null \
| awk '{print $2}' | cut -d/ -f1 | sort | uniq -c | sort -rn | head
41 github.com
6 go.uber.org
3 golang.org
2 go.acme.dev
Every line in that tally with a vendor host is a module path you do not control. Move the ones you own under the namespace, gate regressions with ci/check-imports.sh, and keep the meta tag handler a static nginx response.