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
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.com
A 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.TrimPrefix removes the matched route prefix. /fitness/users/123 becomes /users/123 before hitting upstream.
req.Host needs to be rewritten. If you skip this, the upstream may see localhost:8080 instead of its own host and route incorrectly.
X-Forwarded-Host preserves the original host the client requested.
pluginAbortTransport wraps http.DefaultTransport and checks a special abort header. If a plugin sets X-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/plugins
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.com
listen_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 /health returns {"message":"OK"} with status 200. Useful for load balancers and orchestrators.
curl -i http://localhost:8080/health
GET /configz returns the active route config as JSON. Useful for checking what the process actually loaded.
curl http://localhost:8080/configz | jq
Running it
Local:
cp config.example.yml config.yaml
make run
Docker:
docker build -t mini-rproxy:latest .
docker run --rm \
-p 8080:8080 \
-v "$(pwd)/config.yaml:/app/config.yml:ro" \
mini-rproxy:latest
Test with one of the example routes:
# proxies to https://fitness.example.com/hello-demo
curl http://localhost:8080/fitness/hello-demo | jq
To 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 run
Thanks for reading.