Build a Reverse Proxy in Go With httputil ReverseProxy
Build a reverse proxy in Go with httputil.ReverseProxy: Rewrite instead of Director, forwarded headers, error handling, streaming and path based routing.

A reverse proxy is the smallest useful network service you can write in Go, and it is a standard library type. httputil.ReverseProxy takes a request, forwards it to somewhere else, and streams the answer back. That is the same job nginx and Caddy do, and there are good reasons to do it in your own program: an API gateway that applies your auth rules, a development proxy that fans one port out to a front end and a back end, a tenant router, or a thin layer that adds headers before an older service. There are also reasons not to, which come near the end.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This project builds a proxy in steps, measures its behavior against real upstreams started with httptest, and fixes the three mistakes that most home-grown proxies contain. It belongs to the web cluster that ends in a production HTTP server, is the base for a load balancer, and relies on settings from the HTTP client transport and server timeouts. Everything ran on Go 1.25.6, and the RFC 9110 section on proxies and gateways is the specification behind the header rules.
Rewrite, not Director
For years the way to customise a ReverseProxy was the Director function, which received the outgoing request and could change it in place. It has a flaw that took the Go team a while to fix: after Director runs, the proxy removes hop-by-hop headers named in the incoming Connection header. A client could therefore list X-Forwarded-For or an authentication header your director had just set as a hop-by-hop header, and the proxy would strip it before it reached the upstream. Go 1.20 added Rewrite, which runs after that cleanup and receives a ProxyRequest holding both the incoming request (In) and the outgoing one (Out). The package documentation recommends it, and Director is now legacy.
Two helpers cover most needs. pr.SetURL(target) points the outgoing request at the upstream, joining the target's path with the incoming path, and rewrites the Host header to the target. pr.SetXForwarded() sets X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto from the real connection, and does not trust values the client sent. Use pr.Out.Header.Set for everything else.
A proxy with routes
package main
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strings"
"syscall"
"time"
)
// newProxy forwards to target. prefix is stripped from the path before forwarding.
func newProxy(target *url.URL, prefix string) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.Out.URL.Path = "/" + strings.TrimPrefix(strings.TrimPrefix(pr.In.URL.Path, prefix), "/")
pr.Out.URL.RawPath = ""
pr.SetURL(target)
pr.SetXForwarded()
pr.Out.Header.Set("X-Proxy", "gohacks")
pr.Out.Header.Del("Cookie") // this upstream does not need the browser's cookies
},
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 500 * time.Millisecond}).DialContext,
ResponseHeaderTimeout: 300 * time.Millisecond,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
},
ModifyResponse: func(res *http.Response) error {
res.Header.Set("X-Served-By", target.Host)
res.Header.Del("Server") // do not advertise the upstream's software
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
status, msg := http.StatusBadGateway, "upstream unavailable"
switch {
case errors.Is(err, context.DeadlineExceeded) || isTimeout(err):
status, msg = http.StatusGatewayTimeout, "upstream timed out"
case errors.Is(err, context.Canceled):
return // the client went away; nobody is listening
case errors.Is(err, syscall.ECONNREFUSED):
msg = "upstream refused the connection"
}
http.Error(w, msg, status)
},
}
}
func isTimeout(err error) bool {
var ne net.Error
return errors.As(err, &ne) && ne.Timeout()
}
func main() {
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Server", "api-internal/3.1")
fmt.Fprintf(w, "api saw path=%s xff=%q proto=%q cookie=%q via=%q\n",
r.URL.Path, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Forwarded-Proto"),
r.Header.Get("Cookie"), r.Header.Get("X-Proxy"))
}))
defer api.Close()
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(time.Second)
}))
defer slow.Close()
dead := httptest.NewServer(http.NotFoundHandler())
deadURL, _ := url.Parse(dead.URL)
dead.Close() // nothing listens here any more
apiURL, _ := url.Parse(api.URL)
slowURL, _ := url.Parse(slow.URL)
mux := http.NewServeMux()
mux.Handle("/api/", newProxy(apiURL, "/api"))
mux.Handle("/slow/", newProxy(slowURL, "/slow"))
mux.Handle("/gone/", newProxy(deadURL, "/gone"))
front := httptest.NewServer(mux)
defer front.Close()
get := func(path string, hdr map[string]string) {
req, _ := http.NewRequest("GET", front.URL+path, nil)
for k, v := range hdr {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println(path, "client error:", err)
return
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Printf("%-14s %d served-by-set=%v server-header=%q\n %s", path, res.StatusCode,
res.Header.Get("X-Served-By") != "", res.Header.Get("Server"), body)
if !strings.HasSuffix(string(body), "\n") {
fmt.Println()
}
}
get("/api/users/42", map[string]string{"Cookie": "session=secret", "X-Forwarded-For": "6.6.6.6"})
get("/slow/report", nil)
get("/gone/anything", nil)
}/api/users/42 200 served-by-set=true server-header=""
api saw path=/users/42 xff="127.0.0.1" proto="http" cookie="" via="gohacks"
/slow/report 504 served-by-set=false server-header=""
upstream timed out
/gone/anything 502 served-by-set=false server-header=""
upstream refused the connectionFour behaviors are visible in the output. The upstream saw the path with the /api prefix removed, because Rewrite trimmed it before SetURL joined the rest onto the target. The Cookie header never reached it, and X-Proxy did. The client's attempt to forge X-Forwarded-For: 6.6.6.6 was discarded: SetXForwarded replaced it with the real client address (here 127.0.0.1), which is what makes the header trustworthy for logging and rate limiting. If you sit behind another trusted proxy and want to keep its chain, use pr.Out.Header["X-Forwarded-For"] = pr.In.Header["X-Forwarded-For"] before calling SetXForwarded, and only for proxies you control.
The slow upstream hit ResponseHeaderTimeout and turned into a 504 with our own message instead of hanging for a second, and the dead one became a 502 that says the connection was refused. Without the ErrorHandler, both would have been a bare 502 Bad Gateway and a log line. The handler distinguishes timeout from refusal, so the on-call engineer sees which kind of outage this is from the response alone.
The three mistakes in home-grown proxies
No timeouts on the transport. http.DefaultTransport has a dial timeout but no response header timeout, so one upstream that accepts connections and never answers holds a goroutine and a socket for every request routed to it, until the client gives up. The Transport above sets a dial timeout, a response header timeout and a bounded idle pool. The values should come from your upstream's real latency: a rule I use is p99 times three for the header timeout, and never above what your own clients are willing to wait.
Forwarding things the upstream should not see. Hop-by-hop headers (Connection, Keep-Alive, Transfer-Encoding, Upgrade and friends) are removed by the proxy, but application headers are your responsibility. If browser cookies, Authorization tokens meant for the gateway or internal debug headers can reach an upstream that does not need them, strip them in Rewrite. Equally, remove headers from responses that leak internals (Server, X-Powered-By) in ModifyResponse.
Ignoring the client going away. The proxy builds the outgoing request from the incoming request's context, so when a client disconnects, the upstream request is cancelled too. That is what you want, and it is why the handler above returns silently on context.Canceled: writing an error to a closed connection helps nobody and floods the logs with noise. If you replace the context (to add a deadline), derive it from pr.In.Context() and not from context.Background().
Streaming, WebSockets and large bodies
ReverseProxy copies the response body as it arrives, but it buffers writes to the client up to a point. For responses that must be delivered as they are produced, such as server sent events, set FlushInterval: -1 to flush after every write. The proxy sets it automatically for responses with Content-Type: text/event-stream and for responses without a known length, but being explicit documents the intent.
WebSocket upgrades also work: ReverseProxy recognises the Upgrade handshake, hijacks both sides and copies bytes in both directions. You do not get the hub features from the WebSocket article this way, only transport. Request bodies stream through unbuffered, which is good for uploads, but remember to cap them where the proxy is the edge, using the approach in limiting request body size.
Seeing what the proxy does: a logging transport
A proxy that nobody can observe is a proxy nobody trusts. The cleanest place to record what it does is a RoundTripper wrapped around the real transport, because it sees the outgoing request, the response and the upstream latency, and it sees nothing about the client's connection. The program below adds that, propagates a request ID, and checks what happens to the query string, a trailing slash and the Host header.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strings"
"sync/atomic"
)
type logging struct {
next http.RoundTripper
seq *atomic.Int64
}
func (l logging) RoundTrip(r *http.Request) (*http.Response, error) {
res, err := l.next.RoundTrip(r)
status := 0
if res != nil {
status = res.StatusCode
}
fmt.Printf(" [proxy] %s %s host=%s status=%d err=%v\n", r.Method, r.URL.RequestURI(), r.Host, status, err)
return res, err
}
func main() {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "upstream: uri=%s host=%s id=%s", r.URL.RequestURI(), r.Host, r.Header.Get("X-Request-Id"))
}))
defer upstream.Close()
target, _ := url.Parse(upstream.URL + "/v2")
var n atomic.Int64
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(target)
id := pr.In.Header.Get("X-Request-Id")
if id == "" {
id = fmt.Sprintf("req-%04d", n.Add(1))
}
pr.Out.Header.Set("X-Request-Id", id)
},
Transport: logging{next: http.DefaultTransport, seq: &n},
}
front := httptest.NewServer(proxy)
defer front.Close()
for _, path := range []string{"/items?sort=name&page=2", "/items/", "/files/a%2Fb.txt"} {
res, err := http.Get(front.URL + path)
if err != nil {
panic(err)
}
body, _ := io.ReadAll(res.Body)
res.Body.Close()
fmt.Printf("%-26s -> %s\n", path, strings.ReplaceAll(string(body), upstream.Listener.Addr().String(), "UPSTREAM"))
}
} [proxy] GET /v2/items?sort=name&page=2 host= status=200 err=<nil>
/items?sort=name&page=2 -> upstream: uri=/v2/items?sort=name&page=2 host=UPSTREAM id=req-0001
[proxy] GET /v2/items/ host= status=200 err=<nil>
/items/ -> upstream: uri=/v2/items/ host=UPSTREAM id=req-0002
[proxy] GET /v2/files/a%2Fb.txt host= status=200 err=<nil>
/files/a%2Fb.txt -> upstream: uri=/v2/files/a%2Fb.txt host=UPSTREAM id=req-0003The base path /v2 from the target was prepended in every case, the query string passed through untouched, the trailing slash on /items/ survived (a proxy that normalises paths changes what the upstream routes), and the encoded slash in a%2Fb.txt stayed encoded, because SetURL uses RawPath when it is set. The log line shows an empty host= because SetURL clears the outgoing Host so the transport fills in the upstream's address, and the upstream indeed saw its own address; if the upstream routes by host name, as a multi-tenant service would, set pr.Out.Host = pr.In.Host after SetURL to preserve what the client asked for. Each request also got an ID that the upstream can log, so one search across both services' logs finds the whole trip.
That is also where my own earlier path rewriting needs a warning. Stripping a prefix by assigning pr.Out.URL.Path and clearing RawPath, as the first program does, loses encoded characters. For a service with encoded slashes in paths, trim the prefix from both Path and RawPath instead of dropping the second.
Reading the client's address correctly
A proxy changes who the upstream thinks is calling. Every connection the upstream sees comes from the proxy, so r.RemoteAddr there is the proxy's address, and the real client survives only in X-Forwarded-For. That header is a comma separated list, each hop appending the address it saw. The only entries you can believe are the ones added by proxies you run; everything to the left of those is whatever the client chose to claim. So the upstream should take the rightmost address that is not one of your own proxies, or, where there is exactly one proxy layer, the last entry. Taking the first entry, which is what most tutorials show, lets any caller pick the address your rate limiter and audit log record. The X-Forwarded-For client IP tool lets you paste a header and see what each rule would choose.
For service to service traffic inside one network, the PROXY protocol or a trusted header set by the load balancer is cleaner, but the same principle applies: trust is a property of the network path, never of the header's contents.
Connection pool settings are performance settings
The idle connection limit is the setting that most often makes a Go proxy slower than nginx on the same hardware. The default MaxIdleConnsPerHost is 2. A proxy fanning ten concurrent requests to one upstream keeps two connections warm and closes the other eight after use, then dials eight more for the next burst. Each dial is a TCP handshake (and a TLS handshake, if the upstream uses TLS). The next program counts new connections to one upstream under the same load with the default and with a pool sized for the traffic.
package main
import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"sync"
"sync/atomic"
"time"
)
func run(idle int) int64 {
var accepted atomic.Int64
up := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(20 * time.Millisecond)
}))
up.Config.ConnState = func(_ net.Conn, s http.ConnState) {
if s == http.StateNew {
accepted.Add(1)
}
}
up.Start()
defer up.Close()
target, _ := url.Parse(up.URL)
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.Transport = &http.Transport{MaxIdleConnsPerHost: idle}
front := httptest.NewServer(proxy)
defer front.Close()
client := &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 64}}
for round := 0; round < 10; round++ {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
res, err := client.Get(front.URL)
if err == nil {
res.Body.Close()
}
}()
}
wg.Wait()
}
return accepted.Load()
}
func main() {
fmt.Println("100 requests in ten bursts of ten")
fmt.Println("MaxIdleConnsPerHost=2 upstream connections opened:", run(2))
fmt.Println("MaxIdleConnsPerHost=32 upstream connections opened:", run(32))
}100 requests in ten bursts of ten
MaxIdleConnsPerHost=2 upstream connections opened: 82
MaxIdleConnsPerHost=32 upstream connections opened: 10With the default of 2, the proxy opened 82 upstream connections to serve 100 requests, which is the churn described above: nearly every request paid a handshake and left a socket behind in TIME_WAIT. With 32, it opened 10, exactly the size of the busiest burst, and reused them for the other nine rounds. The right number is roughly the peak number of in-flight requests per upstream, and IdleConnTimeout should be a little shorter than whatever the upstream or its load balancer uses to close idle connections, or you will occasionally send a request down a connection the other side just closed.
Limits worth setting on day one
Three more settings turn a working proxy into one that survives a bad afternoon. Set ReadHeaderTimeout on the server that fronts it, so a client dribbling headers cannot hold a goroutine, with the reasoning from the slowloris article. Set MaxHeaderBytes lower than the 1 MB default if no legitimate caller needs that much. And decide what happens at the edge when every upstream is down: a static 503 with a Retry-After header is kinder than a stream of connection errors, and a client library that honours it backs off by itself.
Build it, or run Caddy
Writing your own proxy is the right call when the routing or authentication decision needs your code: a lookup in your database, a signed token to verify, a tenant to resolve. In that case ReverseProxy gives you 90 percent of a gateway in a screenful of code, and all of it is testable with httptest.
It is the wrong call when the requirement is generic. Rate limiting by IP, response caching, compression, automatic certificates, load balancing algorithms, request buffering and access log rotation are all things Caddy, nginx and Envoy do better than a first version of yours, with years of edge cases handled. A combination works well: let Caddy terminate TLS and do the generic work, and keep a small Go proxy behind it for the logic that is yours.
What the proxy should and should not do
A reverse proxy in front of your own services is a fine place for: TLS termination, request IDs, access logs, coarse rate limits, authentication of the caller and path-based routing. Put those in middleware wrapped around the mux, exactly like any other Go middleware.
It is a poor place for retries of non-idempotent requests, caching without a plan for invalidation, or business logic. Retrying a POST that may already have reached the upstream can charge a customer twice; if you retry at all, do it only for connection failures before any bytes were sent, and only for methods that are safe to repeat. And do not rebuild nginx. If you need fine-grained buffering, HTTP caching, rewrite rules and dozens of operational tweaks, run Caddy or nginx and use Go for the part only you can write.
Testing
Everything above runs against httptest.NewServer, which is the right way to test a proxy: real sockets, no mocking of http.RoundTripper, and each behavior (timeout, refusal, header removal) gets its own upstream. Assert on what the upstream received by having it echo the request, as the api handler does here, and on what the client got. Run with -race, because a proxy's whole job is concurrent traffic. The next step up is a load balancer that picks between several upstreams and stops sending to the unhealthy ones, which reuses everything in this article.


