nemunai.re/content/en/post/vanity-url-go-import-nginx/index.md
Pierre-Olivier Mercier ac433560b5
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/cron/woodpecker Pipeline is running
New article about go-get + nginx
2026-04-23 15:54:33 +07:00

9.6 KiB

title date translationKey tags
Serving custom Go import paths for many modules with nginx 2026-04-23 08:44:00 vanity-url-go-import-nginx
golang
nginx
hosting

When publishing Go code, the usual practice is to distribute the sources directly from the forge that hosts them: github.com/org/project, framagit.org/org/project, and so on. It is convenient, but it durably ties the import path to the chosen provider. The day you migrate from one forge to another, all your users have to update their imports, and every historical fork keeps pointing at the old address.

Fortunately, the go-import mechanism lets you decouple the import path from the actual hosting location of the code. All it takes is exposing, on a domain of your choosing, an HTML page containing a <meta name="go-import"> tag that describes where the sources live.

This technique has long been described on Julien Vehent's blog, where he shows how to configure HAProxy to serve a static HTML file returning the appropriate tag for a single module. But as soon as you manage a handful of modules — let alone a whole collection — maintaining one file per module quickly becomes tedious. The historical approach also fails to handle the module's major versions, introduced with Go modules.

My solution was to let nginx handle everything, through a single location using a regular expression.

Why use a custom import path

When a user runs go get git.happydns.org/happydomain, the Go client first queries the URL https://git.happydns.org/happydomain?go-get=1 and looks for a tag such as:

<meta name="go-import" content="git.happydns.org/happydomain git https://framagit.org/happyDomain/happydomain.git">

This tag tells Go:

  1. the prefix that identifies the module;
  2. the version control system in use (here git);
  3. the actual URL to clone the repository from.

As long as you control the git.happydns.org domain, it does not matter where the source code actually lives. You can move from GitHub to GitLab, from GitLab to Codeberg, or even host your own forge: the import paths stay identical, and no user has to change an import statement or a go.mod file.

Since these platforms may change their terms of service unilaterally, it is always preferable to stay independent and avoid further empowering centralised platforms. Keeping a hold on the identity of the modules I publish, without having to run the forge that hosts them myself, matters to me.

Limitations of the per-file HTML approach

The original article suggests serving, for each module, a static HTML file containing the right tag. It works, but has two drawbacks as soon as you go beyond a single module.

First, every new module or rename implies creating or updating an additional file on the web server. For the happyDomain project, I host around thirty modules (the core, the client SDK, checker libraries, and so on): as many files to keep in sync.

Second, since the arrival of Go modules and their semantic import versioning rule, any module whose major version exceeds 1 must be imported with a /v2, /v3, etc. suffix. The path git.happydns.org/happydomain/v2 must then still resolve correctly, returning the same go-import tag as the base module. Serving one HTML file per module and per major version quickly becomes unmanageable.

A generic location in nginx

Rather than multiplying files, I have nginx generate the HTML response directly, from a regular expression that captures both the module name and its optional version suffix.

Here is the configuration running on git.happydns.org:

server {
    # [...]

    location / {
        rewrite ^(.*)$ https://framagit.org/happyDomain$1;
    }

    location ~ ^/(golang-sdk|happydomain|happyDomain/happyDeliver|happydeliver|checker-sdk-go|checker-(caa|dav|delegation|dummy|email-autoconfig|kerberos|ldap|matrix|ns-restrictions|pgp|ping|smtp|ssh|sip|srv|stun-turn|tls|zonemaster))(/v[0-9])?$ {
        default_type text/html;
        return 200 '<!DOCTYPE html><html><head>
  <meta name="go-import" content="git.happydns.org/$1$3 git https://framagit.org/happyDomain/$1.git">
  <meta http-equiv="refresh" content="0; url=https://framagit.org/happyDomain/$1">
  <link rel="me" href="https://floss.social/@happyDomain">
  </head><body>Nothing to see here; <a href="https://framagit.org/happyDomain/$1">move
  along</a>.</body></html>';
    }
}

Let us break down what is happening.

Dispatching requests

The first location block redirects any request that does not match anything else to the equivalent URL on Framagit. In other words, git.happydns.org/happydomain/-/issues/42 sends the user straight to the right ticket on the forge. Full consistency is preserved between the canonical address and the underlying forge's interface, without duplicating anything.

The last location, finally, is the one we care about: it triggers the generation of the HTML response for the recognised import paths.

The regular expression

The pattern ^/(golang-sdk|happydomain|happyDomain/happyDeliver|...)(/v[0-9])?$ recognises the full set of modules I distribute. The explicit enumeration is deliberate: it acts as an allowlist and prevents any unknown path from triggering the generation of a go-import tag pointing at a non-existent repository.

Two capture groups are defined:

  • $1 matches the module name;
  • $3 matches the version suffix (/v2, /v3, ...) when present, or an empty string otherwise. Group $2 is implicitly used by the inner alternatives of the second checker-(...) sub-pattern.

An HTML page is then built inside the return 200 directive, with elements that vary depending on the captures.

The generated response

The returned page contains three useful elements.

The go-import tag tells the Go client where to fetch the sources:

<meta name="go-import" content="git.happydns.org/$1$3 git https://framagit.org/happyDomain/$1.git">

Note that the announced path includes the version suffix ($3) when present, so that git.happydns.org/happydomain/v2 indeed returns git.happydns.org/happydomain/v2 as the module identifier. The repository URL itself does not carry it: Go will resolve the correct branch or tag from the contents of the repository.

A meta refresh directive redirects human browsers to the project's page on Framagit. This way, when a developer pastes an import path found in a go.mod into their browser, they land straight on the repository page instead of facing a blank page.

Finally, a rel="me" link associates the domain with the project's Mastodon account, for identity verification on the fediverse.

Testing the result

You can verify that the configuration works with curl, passing the ?go-get=1 parameter that Go systematically appends:

42sh$ curl -s 'https://git.happydns.org/happydomain?go-get=1'
<!DOCTYPE html><html><head>
  <meta name="go-import" content="git.happydns.org/happydomain git https://framagit.org/happyDomain/happydomain.git">
  [...]

42sh$ curl -s 'https://git.happydns.org/happydomain/v2?go-get=1'
<!DOCTYPE html><html><head>
  <meta name="go-import" content="git.happydns.org/happydomain/v2 git https://framagit.org/happyDomain/happydomain.git">
  [...]

To make sure the Go client accepts the response, the -x option of go get (or the GOFLAGS="-x" variable) prints in detail all the requests performed during resolution.

{{% card color="info" title="An allowlist, not a catch-all" %}} It would be tempting to replace the explicit enumeration with a generic pattern such as ^/([a-z0-9-]+)(/v[0-9])?$. I prefer to avoid it: a typo in an import would produce a 200 OK response with a go-import tag pointing at a non-existent repository, and the resulting error message would be far less explicit than an honest 404. {{% /card %}}

{{% card color="danger" title="Beware of HTML injection" %}} Be especially careful with how tight your regular expression is: the captured content is injected as-is into the body of the HTML response, without any escaping on nginx's side. A pattern as permissive as ^/(.+)(/v[0-9])?$ would open the door to HTML injection, or even cross-site scripting (XSS), by sneaking code into the URL. Always restrict the accepted characters to the bare minimum ([a-zA-Z0-9_/-] at most), and prefer an allowlist whenever possible. {{% /card %}}

Adding a new module

Whenever I add a module to the project, the procedure boils down to two changes in the nginx configuration file:

  1. add the module name to the regular expression;
  2. reload the configuration with nginx -s reload.

The module becomes immediately importable under git.happydns.org/new-module, and its major version /v2 works without anything else to add.


This technique has been running in production for several years on git.happydns.org without me ever having to worry about it. It guarantees that, should I one day decide to leave Framagit for another forge, I only need to update a single URL in my nginx configuration for every current and past user of these modules to keep accessing the code, without changing a single line on their side.

A project's identity deserves to be managed independently of the tool that hosts it. A few lines of nginx are enough to give yourself that.