Skip to content
gohacks.dev
Web

Subdomain Routing With the Standard Mux in Go Apps

Route api, admin and www hosts to different handlers with one http.ServeMux by putting the host in the pattern, and learn how it treats ports and case.

4 min read
Summarize in AI
Flat illustration of a request passing through concentric middleware layers to a handler.

When one binary serves www, api and admin, the first instinct is an if r.Host == ... ladder at the top of a handler. The standard mux already does this job, and since Go 1.22 the pattern syntax says so in the documentation: a pattern may begin with a host name, written right before the path.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The program below registers two host patterns and a default, then sends requests with awkward hosts. Between the client and the mux sits a three-line wrapper that lowercases the host; the run goes once without it and once with it.

main.goGo
package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

func newMux() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("api.example.test/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "api") })
	mux.HandleFunc("GET admin.example.test/users/{id}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "admin user "+r.PathValue("id"))
	})
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "default site") })
	return mux
}

// normalizeHost lowercases the host and drops one trailing dot, which the mux does not do.
func normalizeHost(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		r.Host = strings.TrimSuffix(strings.ToLower(r.Host), ".")
		next.ServeHTTP(w, r)
	})
}

func main() {
	cases := []struct{ method, host, path string }{
		{"GET", "api.example.test", "/v1/ping"},
		{"GET", "api.example.test:8443", "/v1/ping"},
		{"GET", "API.Example.Test", "/v1/ping"},
		{"GET", "api.example.test.", "/v1/ping"},
		{"GET", "admin.example.test", "/users/7"},
		{"POST", "admin.example.test", "/users/7"},
		{"GET", "admin.example.test", "/dashboard"},
		{"GET", "tenant1.example.test", "/"},
	}
	for _, h := range []struct {
		label   string
		handler http.Handler
	}{{"mux alone", newMux()}, {"with normalizeHost", normalizeHost(newMux())}} {
		fmt.Println(h.label)
		for _, c := range cases {
			req := httptest.NewRequest(c.method, "http://placeholder"+c.path, nil)
			req.Host = c.host
			rec := httptest.NewRecorder()
			h.handler.ServeHTTP(rec, req)
			fmt.Printf("  %-4s %-24s %-10s -> %d %s\n", c.method, c.host, c.path, rec.Code, rec.Body.String())
		}
	}
}
Text
mux alone
  GET  api.example.test         /v1/ping   -> 200 api
  GET  api.example.test:8443    /v1/ping   -> 200 api
  GET  API.Example.Test         /v1/ping   -> 200 default site
  GET  api.example.test.        /v1/ping   -> 200 default site
  GET  admin.example.test       /users/7   -> 200 admin user 7
  POST admin.example.test       /users/7   -> 200 default site
  GET  admin.example.test       /dashboard -> 200 default site
  GET  tenant1.example.test     /          -> 200 default site
with normalizeHost
  GET  api.example.test         /v1/ping   -> 200 api
  GET  api.example.test:8443    /v1/ping   -> 200 api
  GET  API.Example.Test         /v1/ping   -> 200 api
  GET  api.example.test.        /v1/ping   -> 200 api
  GET  admin.example.test       /users/7   -> 200 admin user 7
  POST admin.example.test       /users/7   -> 200 default site
  GET  admin.example.test       /dashboard -> 200 default site
  GET  tenant1.example.test     /          -> 200 default site

Three behaviors stand out in the first block of output. The port is ignored: api.example.test:8443 reached the API handler, which is what you want when the same binary runs on a development port. Case is not ignored: API.Example.Test fell through to the default site, even though DNS names are case insensitive and browsers lowercase them, so this only bites with hand-built requests and the odd client, but it bites silently, and a request meant for api lands on the public site. A trailing dot (api.example.test., legal in DNS) misses the same way. The second block shows the wrapper fixing both. Because it is one function, I put it first in the chain on any server that routes on hosts.

The POST row is another thing to know. The admin pattern says GET, so a POST to /users/7 does not match it, but the default pattern / has no method restriction, so the request is served by the default site with a 200, not rejected with a 405. The mux only answers 405 when no pattern matches the path at all. If you want the admin host to refuse everything else, register admin.example.test/ as its own catch-all, which returns 404, as covered in the custom 404 tip. The rules for method patterns and precedence are in the Go 1.22 mux article.

One deployment detail decides whether any of this works in production: the mux sees the Host your server received. Behind a reverse proxy that is often the internal service name unless the proxy forwards the original (nginx needs proxy_set_header Host $host;, and Go's own ReverseProxy needs pr.Out.Host = pr.In.Host). Log r.Host once, on a real request, before debugging the patterns themselves. Two limits remain, both worth knowing before you commit to this design. Patterns need a literal host, so {tenant}.example.test/ is invalid, and per-customer subdomains need the resolver from the multi tenant routing article. And for local work, *.localhost resolves to the loopback address in current browsers, so http://api.localhost:8080 works without editing /etc/hosts. The pattern grammar is defined in the net/http documentation.

Taggedquick tip
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.