Building a mini reverse proxy in Go
A reverse proxy sits between clients and your backend servicehub. A request comes in, the proxy picks where it goes, forwards it, and sends the response back. That is the whole job.
I like building small versions of tools like this because they make the “magic” easier to see. Once you build one, HTTP routing and middleware stop feeling abstract.
This post walks through mini-rproxy, a small reverse
proxy written in Go. It supports path prefix routing, header
normalization, a health endpoint, and runtime plugins for request and
response processing.
The full source is at github.com/rhighs/mini-rproxy.
Talbe of contents
- What a reverse proxy actually does
- Architecture
- Route matching
- The proxy handler
- Plugin system
- Config format
- Health and configz endpoints
- Running it
- To wrap up
What a reverse proxy actually does
With a forward proxy, you configure your browser or machine to send traffic through it, and it speaks on your behalf.
With a reverse proxy, the client usually has no idea it’s there. The client hits one public address, and the proxy quietly decides which upstream service should handle the request.
Nginx, Caddy, Envoy, and Kong are all reverse proxies at heart. Their extra features, like TLS termination, load balancing, auth, and rate limiting, are the product. Under that, the loop is still simple: receive request, match route, forward request, return response.
Building one from scratch makes you think about details most
frameworks hide. You have to decide how Host headers work
with upstreams, how query params are preserved, what happens to hop by
hop headers, and where request and response interception should
live.
Architecture
Here’s the full request flow:
┌─────────────────────────────────────┐
│ mini-rproxy │
│ │
Client │ ┌──────────┐ ┌────────────────┐ │
───────── HTTP ──► │ │ Router │───►│ Plugin (Req) │ │
request │ │(prefix) │ └───────┬────────┘ │
│ └──────────┘ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ ReverseProxy │ │
│ │ (Director) │ │
│ └───────┬───────┘ │
└──────────────────────────┼──────────┘
│
│ HTTP
▼
┌───────────────────────────┐
│ Upstreams │
│ /fitness → service A │
│ /engine → service B │
│ /connect → service C │
└───────────────────────────┘
│
│ response
▼
┌──────────────────────────────────────┐
│ Plugin (Resp) → Client │
└──────────────────────────────────────┘
The flow is straightforward: route by prefix, rewrite and forward, then let plugins hook into request and response phases.
Route matching
Routes are prefix-based. Each route maps a path prefix to an upstream base URL:
routes:
- prefix: /fitness
upstream: https://fitness.example.com
- prefix: /engine
upstream: https://engine.example.comA request to /fitness/users/123 matches
/fitness, then that prefix gets stripped before forwarding.
The upstream receives /users/123.
The matching rule is longest prefix wins. If both
/api and /api/v2 exist, a request to
/api/v2/users should go to /api/v2, not
/api.
In mini-rproxy, that behavior is implemented with a
simple linear scan:
func findRoute(routes []Route, p string) (Route, bool) {
var best Route
hit := false
bestLen := -1
for _, r := range routes {
if strings.HasPrefix(p, r.Prefix) && len(r.Prefix) > bestLen {
best = r
hit = true
bestLen = len(r.Prefix)
}
}
return best, hit
}For a small route table, this is fine. If you had thousands of routes, you would likely switch to a trie or radix tree. I kept it linear because it is easy to read and debug.
The proxy handler
Go’s standard library includes
net/http/httputil.ReverseProxy. It handles forwarding,
connection reuse, and response copying. You mainly customize the
Director function, where you change the outgoing request
before it is sent:
proxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL.Scheme = up.Scheme
req.URL.Host = up.Host
req.URL.Path = strings.TrimPrefix(req.URL.Path, route.Prefix)
req.Host = up.Host
req.Header.Set("X-Forwarded-Host", req.Host)
},
Transport: &pluginAbortTransport{base: http.DefaultTransport},
}A few details are easy to miss:
strings.TrimPrefixremoves the matched route prefix./fitness/users/123becomes/users/123before hitting upstream.req.Hostneeds to be rewritten. If you skip this, the upstream may seelocalhost:8080instead of its own host and route incorrectly.X-Forwarded-Hostpreserves the original host the client requested.pluginAbortTransportwrapshttp.DefaultTransportand checks a special abort header. If a plugin setsX-MiniRProxy-Plugin-Abort, the proxy exits early instead of forwarding.
type pluginAbortTransport struct {
base http.RoundTripper
}
func (t *pluginAbortTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if msg := req.Header.Get(abortHeader); msg != "" {
return nil, errors.New(msg)
}
return t.base.RoundTrip(req)
}Plugin system
The plugin system is where this project got interesting for me.
Instead of hardcoding middleware, mini-rproxy loads
.so files at startup, Go shared objects compiled with
-buildmode=plugin. This lets you add request and response
behavior without rebuilding the proxy binary.
The plugin interface
Every plugin implements two methods:
type Plugin interface {
Name() string
Handle(ctx *Context) error
}Context carries everything a plugin needs: the request,
the response (during response phase), a shared values map for passing
state between phases, and the current phase:
type Context struct {
Phase Phase
Request *http.Request
Response *http.Response
Values map[string]any
}Plugin phases
Request arrives
│
▼
┌──────────────┐
│ PhaseRequest │ ← read/modify headers, body, or abort entirely
└──────┬───────┘
│
▼
Forward to upstream
│
▼
┌───────────────┐
│ PhaseResponse │ ← read/modify response headers, body
└──────┬────────┘
│
▼
Response sent to client
Each plugin is called twice per request: once before forwarding and
once after the upstream responds. The Phase field tells the
plugin which stage it is in.
Loading plugins at runtime
On startup, the proxy scans -plugindir for
.so files and loads each one:
plug, err := plugin.Open(path)
sym, err := plug.Lookup("MiniRProxyPluginInstance")
p := sym.(pluginapi.Plugin)
plugins = append(plugins, p)Go’s plugin package resolves exported symbols by name.
By convention, each mini-rproxy plugin exports
MiniRProxyPluginInstance. If it is missing, loading
fails.
One gotcha is that the main binary and all plugins must be built with
the same Go version and the same pluginapi package. If they
drift, plugin loading can panic. If you upgrade Go or change interfaces,
rebuild everything together.
Writing your own plugin
Here is a minimal plugin that adds a header in both phases:
package main
import "github.com/tgym-digital/mini-rproxy/engine/pluginapi"
type HeaderDemo struct{}
func (h *HeaderDemo) Name() string { return "header-demo" }
func (h *HeaderDemo) Handle(ctx *pluginapi.Context) error {
switch ctx.Phase {
case pluginapi.PhaseRequest:
ctx.Request.Header.Set("X-Demo-Request", "hello")
case pluginapi.PhaseResponse:
if ctx.Response != nil {
ctx.Response.Header.Set("X-Demo-Response", "hello")
}
}
return nil
}
var MiniRProxyPluginInstance pluginapi.Plugin = &HeaderDemo{}Build it and run:
go build -buildmode=plugin -o bin/plugins/headerdemo.so ./plugins/headerdemo
./bin/mini-rproxy -config ./config.yaml -plugindir ./bin/pluginsConfig format
Configuration lives in one YAML file:
listen_addr: ":8080"
routes:
- prefix: /fitness
upstream: https://fitness.example.com
- prefix: /engine
upstream: https://engine.example.com
- prefix: /connect
upstream: https://connect.example.comlisten_addr is where the proxy listens.
routes is a list of prefix to upstream mappings. Longest
prefix match wins.
CLI flags:
| Flag | Default | Description |
|---|---|---|
-config |
config.yaml |
Path to YAML config |
-verbose |
false |
Log each proxied request with upstream info |
-plugindir |
(empty) | Directory to scan for .so plugins |
Health and configz endpoints
Two built-in endpoints bypass route matching:
GET /healthreturns{"message":"OK"}with status 200. Useful for load balancers and orchestrators.
curl -i http://localhost:8080/healthGET /configzreturns the active route config as JSON. Useful for checking what the process actually loaded.
curl http://localhost:8080/configz | jqRunning it
Local:
cp config.example.yml config.yaml
make runDocker:
docker build -t mini-rproxy:latest .
docker run --rm \
-p 8080:8080 \
-v "$(pwd)/config.yaml:/app/config.yml:ro" \
mini-rproxy:latestTest with one of the example routes:
# proxies to https://fitness.example.com/hello-demo
curl http://localhost:8080/fitness/hello-demo | jqTo wrap up
The most interesting parts were not route matching or YAML parsing.
Those were straightforward. The hard parts were where HTTP details leak
through: Host header behavior, hop by hop headers,
thankfully handled by httputil.ReverseProxy, and plugin
aborts that needed a transport wrapper.
httputil.ReverseProxy does most of the heavy lifting,
and it is worth reading the
source. The Director pattern is simple. You get a
pointer to the outgoing request and adjust it in place.
If I revisited this project, I would redesign the plugin layer first.
Go’s native plugin mechanism works, but the build coupling between the
main binary and .so files is fragile. For many teams, an in
process middleware chain
([]func(http.Handler) http.Handler) is simpler. If runtime
extensibility is a hard requirement, WebAssembly based plugins are
probably a better fit.
Full source:
git clone https://github.com/rhighs/mini-rproxy.git
cd mini-rproxy
cp config.example.yml config.yaml
make runThanks for reading.