Iain Cambridge recently described a company that ended up using GitLab, GitHub, and Azure DevOps at the same time because moving code between hosts had become too expensive. The code itself was not trapped by a proprietary build system. The trap was simpler: the hosting provider's domain had become part of the Go module path.
That is an architectural dependency hiding inside a naming convention.
Go makes repository-shaped module paths convenient. A module such as github.com/acme/widgets tells the toolchain where the code probably lives, gives readers a familiar place to inspect it, and works with almost no setup. The cost appears later, when the repository owner wants to change hosts, split infrastructure, mirror code, or move to an internal forge.
A stable module path changes that tradeoff. Instead of naming the current hosting company, it names a domain you control and lets that domain tell the Go toolchain where the repository lives today.
This is not about avoiding GitHub. It is about making GitHub an implementation detail rather than part of your public package identity.
The coupling is in the module path, not the Git remote
A Go module is identified by the path declared in go.mod. That path also becomes the prefix for package imports inside the module. If you publish a public library with this declaration:
module github.com/acme/widgets
consumer code naturally imports packages from that namespace:
import "github.com/acme/widgets/parser"
Changing the Git remote on your own workstation does not change that identity. You can point origin at another server and keep developing, but consumers still ask the Go toolchain for github.com/acme/widgets. The public name and the original host remain attached.
That distinction matters because repository location and module identity solve different problems. A Git remote answers where maintainers push code. A module path answers what downstream builds request.
Why a custom domain changes the boundary
The Go modules reference allows a module path to begin with a domain you control. When the path does not directly identify a supported repository host, the go command performs an HTTP lookup using the module path and looks for a go-import meta tag.
That makes a path such as this possible:
module go.example.com/widgets
The public package identity is now go.example.com/widgets, while the repository can still live on GitHub. The domain answers the lookup request and points the Go toolchain at the current source repository.
A minimal response can contain a tag like this:
<meta name="go-import"
content="go.example.com/widgets git https://github.com/acme/widgets">
If the repository later moves to another Git server, the module path does not need to change. The operator updates the repository URL returned by the domain. New consumers continue to request go.example.com/widgets.
This is the same reason organizations use stable DNS names in front of replaceable infrastructure. A name controlled by the application owner can stay fixed while the service behind it changes.
What the Go toolchain actually asks for
The custom domain is not magic. It participates in a defined resolution protocol.
When the go command needs a module directly from version control, it can request a URL based on the module path with the query parameter go-get=1. The response must place the go-import meta tag in the document head, early enough for the restricted parser to find it.
For a module named go.example.com/widgets, the lookup is conceptually equivalent to:
https://go.example.com/widgets?go-get=1
The meta tag contains three important pieces: the module root path, the version-control type, and the repository URL. The root path must match the module being requested or be a valid prefix that can be verified by another lookup.
A small redirect service is enough
You do not need a package registry to own the namespace. A tiny HTTP handler can answer module discovery requests while normal browser visits redirect to project documentation.
func moduleHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("go-get") == "1" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<html><head><meta name="go-import" content="go.example.com/widgets git https://github.com/acme/widgets"></head></html>`)
return
}
http.Redirect(w, r, "https://github.com/acme/widgets", http.StatusFound)
}
The important property is ownership. Your DNS, TLS certificate, and HTTP response define the stable name. GitHub, GitLab, a self-hosted forge, or another supported VCS endpoint can sit behind it.
For a larger organization, this endpoint can be generated from a table of module roots and repository URLs. The routing layer should remain boring. It is naming infrastructure, not application logic.
Migration gets easier when the stable name exists first
The best time to choose a durable module path is before the first public release. Once downstream modules import a path, changing it becomes an ecosystem migration rather than a repository migration.
Suppose version one is published as github.com/acme/widgets. Moving the repository later does not automatically rename existing imports. A replace directive can help a maintainer test an alternate source locally, but it is not a global redirect for every consumer. Each downstream module controls its own dependency graph.
By contrast, if version one starts as go.example.com/widgets, the host can move without asking every consumer to edit source code. The module identity survives because consumers never imported the hosting provider's namespace.
There is still operational work. The new repository must contain the expected tags, module contents, and go.mod. Access controls must work. The vanity domain must continue serving the correct metadata. The point is not zero migration work. The point is that the migration stays behind an interface you own.
Public modules and private modules have different failure modes
For public modules, the main concerns are durable naming, tag continuity, and making the discovery endpoint available. Module proxies may cache released versions, which helps consumers keep building older releases even if the repository later moves. Future versions still need a resolvable module path and a reachable source.
Private modules add authentication and proxy policy. The Go reference documents GOPRIVATE for module prefixes that should not use the public proxy or checksum database. A private organization might publish modules under a namespace such as go.corp.example.com/team/service while routing discovery to an internal Git server.
The naming idea remains the same: expose a stable application-owned prefix, then keep repository credentials and transport details behind it.
One useful rule is to separate three questions during design review:
- What name will downstream source code import?
- Which service currently stores the Git repository?
- Which credentials and proxy rules are required to fetch it?
If the same string is answering all three questions, the design is probably more coupled than it needs to be.
Stable import paths do not remove semantic versioning rules
Owning the domain does not let a module ignore Go's version rules. Major versions after version one still require the expected path suffix. A version two module should use a path ending in /v2, regardless of whether the prefix is a GitHub domain or your own.
module go.example.com/widgets/v2
The custom domain protects the repository boundary. It does not change how module versions identify incompatible API lines.
The same warning applies to repository subdirectories. A module's declared path, repository root, subdirectory, and tags still have to agree with the module rules. A vanity domain is a routing layer, not permission to make version layout ambiguous.
Test the namespace like production infrastructure
A stable import domain can become more important than the Git host because every clean environment depends on it during resolution. Treat it as infrastructure.
At minimum, test the discovery response, TLS renewal, repository target, and a clean module download. Do the test from an environment that does not already have the module in its cache. A warm developer machine can hide a broken discovery endpoint.
A simple CI check can request the discovery URL, assert that the expected go-import tag is present, then create a temporary module and resolve a released version. The check should fail before a DNS or routing change reaches users.
The common shortcuts fail for predictable reasons
One shortcut is to keep the GitHub module path and assume a repository transfer will solve future moves. That works only while the desired destination remains compatible with the old public name. A transfer inside GitHub may preserve useful redirects for web traffic, but the module identity is still a GitHub-owned namespace. Moving to a different host is a different problem.
Another shortcut is to plan a mass search-and-replace later. That changes your own repository, not every consumer repository. Public libraries may have forks, tutorials, generated code, internal mirrors, and old services importing the earlier path. A source rewrite is therefore a compatibility event.
A third shortcut is to use replace directives as a migration mechanism. They are excellent for local development and controlled builds, but each main module owns its replacements. A library cannot publish a replace directive that globally rewires all of its consumers.
A fourth shortcut is to make the vanity endpoint depend on a large application stack. That turns a simple naming dependency into another fragile service. The discovery response should be small enough to serve from a static host, edge rule, or minimal handler.
When a GitHub-shaped path is still reasonable
Not every Go repository needs a custom domain.
A short-lived internal tool may never become a dependency. A prototype may be intentionally tied to one organization and one host. A personal project may value zero infrastructure more than future portability. In those cases, github.com/owner/repo is direct and easy to understand.
The calculation changes when the module becomes a durable public API. If other teams will import it for years, if multiple repositories share an organizational namespace, or if host migration is a realistic possibility, the cost of owning a small stable domain is easier to justify.
The decision is similar to choosing a public API hostname. You can expose the current server name directly, but once clients depend on it, renaming becomes coordination work.
A practical rollout checklist
For a new public Go module, the rollout can stay simple.
First, choose a module prefix under a domain the organization intends to keep. Second, configure the go-import response before publishing the first tagged release. Third, verify resolution from a clean environment. Fourth, keep the repository target configurable rather than hard-coded across many pages. Fifth, document who owns the DNS and discovery endpoint so the module does not become orphaned during an infrastructure handoff.
For an existing module already published under GitHub, do not pretend a rename is free. Decide whether the compatibility cost is worth paying. If it is, publish a migration plan, keep old documentation available, and avoid moving the repository and renaming the module in the same opaque step. Consumers need to understand whether they are changing a source location, a module identity, or both.
The deeper principle is small but useful: names that appear in other people's source code should be controlled by the party promising their stability.
GitHub can remain the repository host. The module path does not have to advertise that fact forever.
Sources
- Iain Cambridge, "Don't couple your Go code to GitHub": https://iain.rocks/blog/dont-couple-your-go-code-to-github
- Go Modules Reference, module paths and repository discovery: https://go.dev/ref/mod
The Hacker News discussion surfaced the topic on September 28, 2026. The implementation details above follow the Go module reference rather than assuming a hosting provider can transparently rename a public module namespace.
Originally published on Dispatch.
Top comments (0)