Serving Go Modules with Hugo

∼ 9 minutes / 1800 words

Serving Go modules off a custom domain (such as go.deuill.org) requires only that you’re able to serve static HTML for the import paths involved; as simple as this sounds, finding salient information on the mechanics involved can become quite involved.

This post serves as a soft description into the mechanics of how go get resolves modules over HTTP, and walks through setting up Hugo (a static site generator) for serving Go modules off a custom domain.

Why would anyone want to do this? Aside from indulging one’s vanities, custom Go module paths can potentially be more memorable, and control over these means you can move between code hosts without any user-facing disruption.

Basic Mechanics

At a basic level, go get resolves module paths to their underlying code by making HTTP calls against the module path involved; responses are expected to be in HTML format, and must contain a go-import meta tag pointing to the code repository for a supported version control system, e.g.:

<meta name="go-import" content="go.example.com/example-module git https://github.com/deuill/example-module.git">

In this case, the go-import tag specifies a module with name (or, technically, the “import prefix”, as we’ll find out in later sections), go.example.com/example-module, which can be retrieved from https://github.com/deuill/example-module.git via Git.

Go supports a number of version control systems, including Git, Mercurial, Subversion, Fossil, and Bazaar; nevertheless, and assuming the meta tag is well-formed, go get will then continue to pull code for the repository pointed to, for the VCS chosen.

Thus, with this content served over https://go.example.com/example-module, we can then make our go get call and see code pulled from Github:

$ go get -v go.example.com/example-module
go: downloading go.example.com/example-module v0.0.0-20230325162624-6da6d8c20f04

The repository pulled must be a valid Go module – that is, a repository containing a valid go.mod file – for it to resolve correctly; this file must also contain a module directive that has the same name as the one being pulled.

…that’s pretty much it!

It is, of course, quite plausible that we can just stuff static HTML files in our web root and be done with it, but where’s the fun in that? Furthermore, Hugo allows us to create complex page hierarchies using simple directives, which is definitely of use to us here.

Hugo Setup

In order to get a workable Go module host set up in Hugo, we need two things: content items, each representing a Go module, and a set of templates to render these as needed.

First, we’ll need to set up a new site with Hugo:

$ hugo new site go.deuill.org

This will set up a fairly comprehensive skeleton for our Go module host, but will need some love before it’s anywhere near useful. First, add some basic, site-wide configuration – open hugo.toml and set your baseURL and title to whatever values you want, e.g.:

# hugo.toml
baseURL = 'https://go.deuill.org'
languageCode = 'en-us'
title = 'Go Modules on go.deuill.org'

Next, let’s add a basic module skeleton as a piece of content – a Markdown file in the content directory named example-module.md. This file doesn’t actually need to contain any content per se: rather, we’ll be looking to use Hugo front matter, i.e. page metadata, to describe our modules (though of course the extent to which we take rendering content is entirely up to us):

# content/example-module.md
---
title: Example Module
description: An example Go module with a custom import path
---

Since Hugo doesn’t create any default templates for rendering content pages, we’ll need to create some basic ones ourselves; at a minimum, we’ll need a page for the content itself, but ideally we’d also want to be able to see all modules available on the host.

Let’s tackle both in turn – for content-specific rendering, we’ll need to add a template file in layouts/_default/single.html. The content can be quite minimal for the moment:

<!-- layouts/_default/single.html -->
<!doctype html>
<html lang="{{.Site.LanguageCode}}">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <title>{{.Title}} - {{.Site.Title}}</title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
    </head>
    <body>
        <main>
            <h1>{{.Title}}</h1>
            <aside>{{.Description}}</aside>
        </main>
    </body>
</html>

Similarly, our home-page template is located in layouts/index.html, and looks like this:

<!-- layouts/index.html -->
<!doctype html>
<html lang="{{.Site.LanguageCode}}">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <title>Go Modules - {{.Site.Title}}</title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
    </head>
    <body>
        <main>
        <h1>{{.Site.Title}}</h1>
            {{range .Site.RegularPages}}
            <section>
                <h2><a href="{{.Permalink}}">{{.Title}}</a></h2>
                <aside>{{.Description}}</aside>
            </section>
            {{end}}
        </main>
    </body>
</html>

So far, so good – if you’ve been following along here, you should, by this point, have a fairly brutalist listing of one solitary Go module, itself not quite ready for consumption by go get as-of-right-now.

Serving Go Modules

Serving a Go module is, as shown above, a simple matter of rendering a valid go-import tag for the same URL pointed to by the import path itself. Rendering a go-import tag requires three pieces of data, at a minimum:

  • The module path (e.g. go.example.com/example-module).
  • The source control protocol, e.g. git or hg.
  • The source control repository URL, e.g. https://github.com/deuill/example-module.

Hugo supports adding arbitrary key-values in content front matter, which can then be accessed in templates via the {{.Params}} mapping. Simply enough, we can extend our content file example-module.md with the following values:

# content/example-module.md
---
title: Example Module
description: An example Go module with a custom import path
+module:
+  path: go.example.com/example-module
+repository:
+  type: git
+  url: https://git.deuill.org/deuill/example-module.git
---

Producing a valid go-import tag is, then, just a matter of referring to these values in the content-specific layout, layouts/_default/single.html; we can also render them out on-page to make things slightly more intuitive for human visitors, e.g.:

<!-- layouts/_default/single.html -->
<!doctype html>
<html lang="{{.Site.LanguageCode}}">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <title>{{.Title}} - {{.Site.Title}}</title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
+        <meta name="go-import" content="{{.Params.module.path}} {{.Params.repository.type}} {{.Params.repository.url}}">
    </head>
    <body>
        <main>
            <h1>{{.Title}}</h1>
            <aside>{{.Description}}</aside>
+            <dl>
+                <dt>Install</dt>
+                <dd><pre>go install {{.Params.module.path}}@latest</pre></dd>
+                <dt>Documentation</dt>
+                <dd><a href="https://pkg.go.dev/{{.Params.module.path}}">https://pkg.go.dev/{{.Params.module.path}}</a></dd>
+            </dl>
        </main>
    </body>
</html>

Ship it! This setup is sufficient in serving multiple Go modules, each in their own directories, and with a functional, albeit somewhat retro, human-facing interface.

Sub-modules and Sub-paths

Most modules would be well-served by this setup, but it does assume that the Go module is placed at the repository root; based on what we know about go get, the import path needs to resolve to an HTML file containing a valid go-import tag, and that import path needs to match the resulting go.mod file.

The module path in the go-import tag can, therefore, be assumed to also be always equal to the import path; closer reading of the official Go documentation reveals that this is instead the import prefix, relating to the repository root and not necessarily to any of the Go modules placed within.

Furthermore, the full path hierarchy must present the same go-import tag in order to resolve correctly with go get. Clearly there’s some headaches to be had.

To better illustrate the issue, let’s assume the following repository structure, containing a number of files in the repository root, as well as a Go module in one of the sub-folders, e.g.:

├── .git
│   └── ...
├── LICENSE
├── README.md
└── thing
    ├── go.mod
    ├── go.sum
    ├── main.go
    └── README.md

The full import path for this module would be go.example.com/example-module/thing, which is also what the module directive would be set to in the go.mod file; however, the import prefix presented in the go-import tag needs to be set to go.example.com/example-module.

Given this conundrum, it is not enough to simply set a different module.path in the content file for example-module.md, or even create a separate example-module/thing.md content file – we need to ensure that the full hierarchy resolves to a valid HTML file containing a valid go-import tag, that, crucially, always points to the import prefix of go.example.com/example-module.

Turns out that Hugo has yet a few tricks up its sleeve, and can assist us in setting up this complex content hierarchy using a single content file, the trick being content aliases.

Aliases are commonly intended with redirecting alternative URLs to some canonical URL via client-side redirects (using the http-equiv="refresh" meta tag); for our use-case, we’ll need to slightly extend the underlying templates and render a valid go-import tag alongside the http-equiv tag.

First, let’s add the sub-path corresponding to the Go module as an alias in our existing example-module.md content file:

# content/example-module.md
---
title: Example Module
description: An example Go module with a custom import path
module:
  path: go.example.com/example-module
repository:
  type: git
  url: https://git.deuill.org/deuill/example-module.git
+aliases:
+  - /example-module/thing
---

Navigating to go.example.com/example-module/thing will, then, render a default page containing a minimal amount of HTML, as well as the aforementioned http-equiv meta tag. We can extend that for our own purposes by adding a layout file in layouts/alias.html, e.g.:

<!-- layouts/alias.html -->
<!DOCTYPE html>
<html lang="en-us">
    <head>
        <title>{{.Page.Title}}</title>
        <link rel="canonical" href="{{.Permalink}}">
        <meta name="robots" content="noindex">
        <meta charset="utf-8">
        <meta http-equiv="refresh" content="5; url={{.Permalink}}">
        <meta name="go-import" content="{{.Page.Params.module.path}} {{.Page.Params.repository.type}} {{.Page.Params.repository.url}}">
    </head>
    <body>
        <main>This is a sub-package for the <code>{{.Page.Params.module.path}}</code> module, redirecting in 5 seconds.</main>
    </body>
</html>

Crucially, this alias has full access to our custom front matter parameters, making integration of additional sub-modules a simple matter of adding an alias in the base content file.

Since our import paths still expect the fully formed path (and not just the prefix), our human-readable code examples rendered for specific modules will be incorrect in the face of sub-packages; solving for this is an exercise left to the reader (hint hint: add a sub field in front matter, render it in the go get example via {{.Params.module.sub}}).

Making It Pretty

If you’re not a fan of the brutalist aesthetic, and would like to waste your human visitors' precious bandwidth with such frivolities as colors, you could spice things up with a bit of CSS. A little goes a long way:

/* static/main.css */
html {
    background: #fefefe;
    color: #333;
}

body {
    margin: 0;
    padding: 0;
}

main {
    margin: 0 auto;
    max-width: 50rem;
}

a {
    color: #000;
    border-bottom: 0.2rem solid #c82829;
    padding: 0.25rem 0.1rem;
    text-decoration: none;
}

a:hover {
	color: #c82829;
}

dl dt {
    font-size: 1.2rem;
    font-weight: bold;
}

dl dd pre,
dl dd a {
    display: inline-block;
}


pre {
	background: #f0f0f0;
	color: #333;
	padding: 0.4rem 0.5rem;
	overflow: auto;
	margin-bottom: 1rem;
	white-space: nowrap;
}

Add this to static/main.css and link to it from within layouts/_default/single.html and layouts/index.html.

The setup described here is directly inspired by and used for hosting my own Go modules, under go.deuill.org. The source-code for the site is available here.