Roberto Montalti

~rhighs

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

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:

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


Config 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.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:

curl -i http://localhost:8080/health
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.