httputil.ReverseProxy

Forward requests to another server with httputil.ReverseProxy, and rewrite them on the way through.

Syntaxproxy := &httputil.ReverseProxy{Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(target) }}

A reverse proxy is a server that answers a request by sending it on to another server, then copying that server's reply back to the caller. The server behind it is called the backend. The caller never learns the backend's address.

It exists so that one public address can stand in front of many services, and so the work every service needs — TLS, logging, authentication, retries — happens in one place instead of being written again in each one.

How it works

The type is httputil.ReverseProxy, from the standard library package net/http/httputil. It is an http.Handler, so it goes anywhere a handler goes. The field you set is Rewrite. That is a function given a *httputil.ProxyRequest, a value with two fields. In is the request as it arrived; you read it and never change it. Out is the request about to be sent; that is the one you change. pr.SetURL(target) aims Out at the backend. pr.SetXForwarded() adds the X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto headers, which tell the backend who really called. Rewrite, ProxyRequest and both methods were added in Go 1.20.

What trips people up

SetURL also rewrites the outbound Host header to the backend's host. If the backend builds links or picks a tenant from Host, add pr.Out.Host = pr.In.Host after it. Any X-Forwarded-* header the client sent is deleted before Rewrite runs, so a client cannot lie about its address — and the backend gets nothing at all unless you call SetXForwarded yourself.

Example

Example · go
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
	"os"
)

func main() {
	// A stand-in for the real service. It reports what reached it.
	backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "path=%s xff-set=%t\n", r.URL.Path, r.Header.Get("X-Forwarded-For") != "")
	}))
	defer backend.Close()

	target := &url.URL{Scheme: "http", Host: backend.Listener.Addr().String()}
	proxy := &httputil.ReverseProxy{Rewrite: func(pr *httputil.ProxyRequest) {
		// Aim the outbound request at the backend.
		pr.SetURL(target)
		// Keep the Host header the client asked for.
		pr.Out.Host = pr.In.Host
		// Record the caller in X-Forwarded-For, -Host and -Proto.
		pr.SetXForwarded()
	}}

	// The proxy is just a handler, so serving it needs nothing special.
	front := httptest.NewServer(proxy)
	defer front.Close()

	resp, err := http.Get(front.URL + "/orders")
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	if _, err := io.Copy(os.Stdout, resp.Body); err != nil {
		panic(err)
	}
}

// Output:
// path=/orders xff-set=true

When to use it

  • Put one public hostname in front of three internal services.
  • Terminate TLS in Go and forward plain HTTP inside the cluster.
  • Serve a single-page app from one origin while /api goes to a separate service.
  • Add a request ID header to every call reaching a legacy service you cannot change.

More examples

Shorthand and longhand

The two forms route the same way, so you can read either in someone else's code. One difference is worth knowing: a proxy using Director appends the client's address to X-Forwarded-For on its own, while the Rewrite form does that only when you call pr.SetXForwarded(). NewSingleHostReverseProxy also joins paths, so a target of http://api:8080/v1 turns an inbound /orders into /v1/orders.

Example · go
func proxies(target *url.URL) {
	// The shorthand. One call, and the Host header is left as the client sent it.
	short := httputil.NewSingleHostReverseProxy(target)

	// The same routing written out. SetURL would point Host at the backend,
	// so the next line puts the client's Host back.
	long := &httputil.ReverseProxy{Rewrite: func(pr *httputil.ProxyRequest) {
		pr.SetURL(target)
		pr.Out.Host = pr.In.Host
	}}

	// Director is the older field, from before Go 1.20, and deprecated since
	// Go 1.26 in favour of Rewrite. It is handed the outbound request itself.
	// Set Rewrite or Director, never both.
	older := &httputil.ReverseProxy{Director: func(out *http.Request) {
		out.URL.Scheme = target.Scheme
		out.URL.Host = target.Host
	}}

	_, _, _ = short, long, older
}

Changing the reply, and handling a dead backend

Reach for ModifyResponse when you want to add or rewrite a response header for every service behind the proxy, and for ErrorHandler when the default 502 is not the answer you want your callers to see. Note the division: ErrorHandler is for failures reaching the backend, not for a 500 the backend itself returned — that is a real response and goes through ModifyResponse.

Example · go
proxy := &httputil.ReverseProxy{
	Rewrite: func(pr *httputil.ProxyRequest) {
		pr.SetURL(target)
		pr.SetXForwarded()
	},
	// Runs for any reply the backend managed to send, whatever its status.
	ModifyResponse: func(resp *http.Response) error {
		resp.Header.Set("X-Served-By", "gateway")
		return nil
	},
	// Runs when the backend could not be reached, or when ModifyResponse
	// returned an error. Without this the proxy logs and sends a bare 502.
	ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
		slog.Error("upstream failed", "path", r.URL.Path, "err", err)
		http.Error(w, "service unavailable", http.StatusServiceUnavailable)
	},
	// A negative value flushes each write to the client immediately,
	// which is what server-sent events need.
	FlushInterval: -1,
}

Mounting a proxy under a path prefix

This is the usual shape when one Go process serves some routes itself and forwards others. The trailing slash in "/api/" matters: it matches every path under /api, while "/api" would match only that exact path. Use this when the backend does not know it is mounted under a prefix and you would rather not teach it.

Example · go
func gateway(proxy http.Handler) http.Handler {
	mux := http.NewServeMux()
	// StripPrefix removes /api before the proxy sees the request, so
	// /api/orders arrives at the backend as /orders.
	mux.Handle("/api/", http.StripPrefix("/api", proxy))
	// A method in the pattern needs Go 1.22 or later.
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	})
	return mux
}

Discussion

  • Be the first to comment on this lesson.