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

176 lines
9.6 KiB
Markdown

---
title: "Serving custom Go import paths for many modules with nginx"
date: !!timestamp '2026-04-23 08:44:00'
translationKey: vanity-url-go-import-nginx
tags:
- 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`](https://go.dev/ref/mod#vcs-find) 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.
<!-- more -->
This technique has [long been described on Julien Vehent's blog](https://jve.linuxwall.info/blog/index.php?post/2015/08/26/Hosting_Go_code_on_Github_with_custom_import_path), 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](https://go.dev/ref/mod#major-version-suffixes).
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:
```html
<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](https://www.happydomain.org/) 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`](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#return) 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:
```html
<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`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#http-equiv) 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](https://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.