Kirtan Soni
← All writing

ServeMux never forgets — a 430-line reverse proxy you reconfigure by typing at it

I wanted every side project reachable under one domain without ever editing a config file again. So I wrote a ~430-line Go reverse proxy whose route table you edit by typing commands into the running server's stdin. Two things happened on the way: Go's ServeMux refused to forget a route, and six lines of autocert made my most thoroughly tested package obsolete.

Heavier than the problem deserved

The goal was simple: every project lives at https://<domain>/projects/<path>, and adding a new one shouldn't mean redeploying anything. nginx does this, obviously.nginx reloads its config with one command, in under a second, without dropping a connection. The heaviness I was avoiding was mostly in my head. Building it anyway was the point. But editing a config file and reloading felt heavier than the problem deserved, and I wanted to know what net/http/httputil actually gives you for free. The bet: one Go binary, a httputil.ReverseProxy per upstream, and a route table you can rewrite while the server is serving.The repo's entire git history is two commits, three minutes apart: "Initial commit" and "final push". All of the actual work happened off the record in between.

client :443 TLS (autocert) security headers /projects/* → route lookup ReverseProxy upstream

Port 80 exists only to answer ACME HTTP-01 challenges and redirect everything else to HTTPS. The real handler chain lives on 443. That part went exactly as planned. The route table did not.

ServeMux never forgets

The core is RuntimeMux: an RWMutex-guarded map of path → Service (name, path, target URL, and a live httputil.NewSingleHostReverseProxy), wrapped around a standard http.ServeMux. Add a route, register the pattern, done. Then I went to write remove and hit the wall immediately: Go's ServeMux has no unregister. Once you HandleFunc a pattern, it's there for the life of the mux. The standard library hands you a registry and quietly omits the eraser.

Decision — resolve the route at request time, not registration time. The handler registered on the mux is a closure that looks the path up in the live map on every request. Add = write to the map (and register the pattern only if it's new). Remove = nil the map entry. Re-adding the same path just swaps the map value, which means you can also update a service's upstream URL live — there's a test that flips a path between two backends and checks the second one answers. The pattern itself never leaves the ServeMux; the closure decides what happens.

One level of indirection, and the immutable registry stops mattering. The map is the truth; the mux is just a doorway.

Four commands, typed at a live server

With the table mutable, the management interface could be almost nothing: an interactive CLI reading the server's own stdin, running in a goroutine next to the listeners. Four commands, zero cleverness:

proxy-server -domain example.com
Proxy Management CLI
Available commands: add, remove, list, exit
> add API /api https://api-backend.example.com
Added service API at path /api
> list
===== Services of RunTime Mux ========
{"name":"API","path":"/api","url":"https://api-backend.example.com"}
======================================
> remove /api
Removed service at path /api

A route added at /api is served at https://<domain>/projects/apihttp.StripPrefix("/projects", ...) sits in front of the runtime mux, so upstreams never learn the prefix exists. That was the whole feature I'd set out to build, working. Routing turned out to be the easy half. Then I had to put HTTPS in front of it.

The 397 lines autocert made redundant

I did TLS the hard way first, on purpose. An ssl package: load a cert/key pair from disk, serve it by SNI through tls.Config.GetCertificate, pinned TLS 1.2+ cipher suites, tests for all of it.For the record: proxy/, the package that runs in production, has 187 lines of tests. ssl/, the package that doesn't, has 311. I tested the corpse more thoroughly than the patient. It worked. Then I read what golang.org/x/crypto/acme/autocert actually does.

Decision — autocert instead of my own cert manager. Issuance, renewal, HTTP-01 challenge handling, and a disk cache — in about six lines of setup. The autocert.Manager hangs its challenge handler on port 80 and its TLSConfig() on the 443 server, and certificates just exist. My hand-rolled package is still in the repo, fully tested and entirely unused. That's the honest cost of learning by building: sometimes the lesson is "delete this." I haven't even managed the delete part.

The rest of main.go is plumbing done the boring, correct way, because a proxy is the one process that's never allowed to be the weak link: a middleware stamps every HTTPS response with the usual security headers (Strict-Transport-Security, Content-Security-Policy with default-src 'self', X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, X-XSS-Protection); both servers get read/write/idle timeouts and a max-header-bytes cap from flags; and SIGINT/SIGTERM trigger http.Server.Shutdown on both with a configurable deadline. The proxy package has tests for routing, fallback, live URL swaps, and concurrent add/remove under -race.

Six unchecked checkboxes

There's a requirements file in the repo — config-file routes, OAuth on certain paths, logging via Loki and Grafana, host-based rerouting, caching, a portfolio site. Every box is still unchecked, and the gaps are real. Routes don't persist: the initial /wordsweave route and the ACME contact email are hardcoded in main.go, and anything added through the CLI dies with the process. remove doesn't really 404: because the ServeMux pattern can't be unregistered, removed paths fall through to a plaintext "Path not found" fallback that returns 200 — a wrong status code that happens to look fine in a browser. The ssl package is dead code, kept around as a candidate for a non-ACME deployment mode the running server has never needed. And the root handler at / is a stub for the portfolio site that doesn't exist yet.Specifically, it returns the five bytes hello. The portfolio has been "next up" since March 2025. Next, in order: config-file-driven routes so the CLI edits something durable, a real 404 on remove, then logging.

I went in to learn what httputil gives you for free, and it does give you a lot — the actual proxying is a one-liner per upstream. The surprise was that the missing pieces were more instructive than the free ones. ServeMux not having an unregister forced the request-time-lookup design, which ended up being the best thing in the codebase — it's what makes live URL swaps possible at all. And autocert went the other way: 397 of my most carefully tested lines, made redundant by six. Build it yourself first; just be ready for the standard library to win.

Stack: Go 1.23 · net/http · net/http/httputil · golang.org/x/crypto/acme/autocert.
Layout: main.go (servers, autocert, headers, shutdown) · proxy/ (RuntimeMux + CLI, tested) · ssl/ (the cert manager autocert made redundant).

Tags: Go, Networking, TLS, Infrastructure