What Proxy Servers Do

A proxy server acts as an intermediary between a client requesting a resource and the server providing it. In practice, proxies come in two flavors: forward proxies, which sit between users and the internet, and reverse proxies, which sit between the internet and backend servers.

Forward proxies are the kind most people encounter at work or school. They sit on the client side of the connection and can enforce browsing restrictions, anonymize traffic by hiding individual users behind one address, or assist with logging and caching. On the other side, reverse proxies are deployed by service operators and are transparent to end users. They handle load balancing, DDoS protection, TLS termination, and similar infrastructure concerns. CDNs like Cloudflare are the most visible example of reverse proxies in the wild — most traffic to large websites passes through at least one of them.

The distinction is really about placement. A forward proxy sits in front of the client and sees that client talking to many different servers. A reverse proxy sits in front of a server and sees many different clients talking to that one upstream service.

A Real Reverse Proxy in Minutes

To see a reverse proxy in action, we can use Caddy, a commercial-grade server that makes the setup trivial. Pointing Caddy at a local test server is just a matter of specifying the listen address and the upstream target:

caddy reverse-proxy --from :9090 --to :8080

Caddy listens on port 9090 and forwards everything to port 8080, where a simple debugging backend runs. That backend responds to any route with a success message and logs details of every request it receives, which makes it a good test bed for proxy behavior.

With that minimal config, curl requests to port 9090 are answered by the backend on 8080. This arrangement immediately unlocks useful capabilities:

  • TLS termination: Caddy can handle HTTPS from the client and talk plain HTTP to the backend, so the backend never has to deal with certificates.
  • Load balancing: Caddy's reverse proxy mode can distribute requests across multiple backends with health checks and configurable strategies.

Rolling Your Own Reverse Proxy

Go's standard library provides most of what you need for a custom reverse proxy through the ReverseProxy type in net/http/httputil. The simplest case — forwarding everything from one address to another — is handled by NewSingleHostReverseProxy:

func main() {
  fromAddr := flag.String("from", "127.0.0.1:9090", "proxy's listening address")
  toAddr := flag.String("to", "127.0.0.1:8080", "the address this proxy will forward to")
  flag.Parse()

  toUrl := parseToUrl(*toAddr)
  proxy := httputil.NewSingleHostReverseProxy(toUrl)
  log.Println("Starting proxy server on", *fromAddr)
  if err := http.ListenAndServe(*fromAddr, proxy); err != nil {
    log.Fatal("ListenAndServe:", err)
  }
}

// parseToUrl parses a "to" address to url.URL value
func parseToUrl(addr string) *url.URL {
  if !strings.HasPrefix(addr, "http") {
    addr = "http://" + addr
  }
  toUrl, err := url.Parse(addr)
  if err != nil {
    log.Fatal(err)
  }
  return toUrl
}

Running this proxy on port 9090 sends all incoming requests to the backend listening on 8080. A custom Director function on the ReverseProxy type can change how requests are routed before they're sent upstream. This is how we can build a basic round-robin load balancer across two backends:

func main() {
  fromAddr := flag.String("from", "127.0.0.1:9090", "proxy's listening address")
  toAddr1 := flag.String("to1", "127.0.0.1:8080", "the first address this proxy will forward to")
  toAddr2 := flag.String("to2", "127.0.0.1:8081", "the second address this proxy will forward to")
  flag.Parse()

  toUrl1 := parseToUrl(*toAddr1)
  toUrl2 := parseToUrl(*toAddr2)

  proxy := loadBalancingReverseProxy(toUrl1, toUrl2)
  log.Println("Starting proxy server on", *fromAddr)
  if err := http.ListenAndServe(*fromAddr, proxy); err != nil {
    log.Fatal("ListenAndServe:", err)
  }
}

func loadBalancingReverseProxy(target1, target2 *url.URL) *httputil.ReverseProxy {
  var targetNum = 1

  director := func(req *http.Request) {
    var target *url.URL
    // Simple round robin between the two targets
    if targetNum == 1 {
      target = target1
      targetNum = 2
    } else {
      target = target2
      targetNum = 1
    }

    req.URL.Scheme = target.Scheme
    req.URL.Host = target.Host
    req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL)
    // For simplicity, we don't handle RawQuery or the User-Agent header here:
    // see the full code of NewSingleHostReverseProxy for an example of doing
    // that.
  }
  return &httputil.ReverseProxy{Director: director}
}

The proxy takes two backend addresses via flags and alternates between them for each incoming request. Run two instances of the debug server on ports 8080 and 8081, start this proxy on 9090, and you'll see requests distributed between both backends in turn.

A natural extension is to implement the other fields on ReverseProxy (the constructor we used fills them in for us), add an arbitrary number of backends, and refine the balancing policy — Caddy's reverse_proxy directive is a good reference for what a production version looks like.

A Simple Forward Proxy

Forward proxies introduce a wrinkle that reverse proxies don't have to deal with. A reverse proxy knows its backend, but a forward proxy receives a request and has to figure out where the client actually wants to go. HTTP is normally written with a relative path, assuming the client is talking directly to the server. When a proxy is in the middle, that path isn't enough — the proxy needs to know the full destination.

That's why HTTP/1.1 requires clients using forward proxies to send an absolute URL in the request line. Compare a direct request:

GET /some/path HTTP/1.1
Host: example.org
User-Agent: curl/7.81.0
Accept: */*

with the same request sent through a proxy (configured via the http_proxy environment variable):

GET http://example.org/foo/bar HTTP/1.1
Host: example.org
User-Agent: curl/7.81.0
Accept: */*
Proxy-Connection: Keep-Alive

Note the full URL after GET instead of a path. One other detail: some HTTP headers are "hop by hop" — they're only meaningful between direct peers, so proxies must remove them before forwarding. Proxy implementations also typically update X-Forwarded-For to track the request's path through intermediaries.

A minimal Go forward proxy can be built around an http.Handler that performs a few steps:

type forwardProxy struct {
}

func (p *forwardProxy) ServeHTTP(w http.ResponseWriter, req *http.Request) {
  // The "Host:" header is promoted to Request.Host and is removed from
  // request.Header by net/http, so we print it out explicitly.
  log.Println(req.RemoteAddr, "\t\t", req.Method, "\t\t", req.URL, "\t\t Host:", req.Host)
  log.Println("\t\t\t\t\t", req.Header)

  if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
    msg := "unsupported protocal scheme " + req.URL.Scheme
    http.Error(w, msg, http.StatusBadRequest)
    log.Println(msg)
    return
  }

  client := &http.Client{}
  // When a http.Request is sent through an http.Client, RequestURI should not
  // be set (see documentation of this field).
  req.RequestURI = ""

  removeHopHeaders(req.Header)
  removeConnectionHeaders(req.Header)

  if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
    appendHostToXForwardHeader(req.Header, clientIP)
  }

  resp, err := client.Do(req)
  if err != nil {
    http.Error(w, "Server Error", http.StatusInternalServerError)
    log.Fatal("ServeHTTP:", err)
  }
  defer resp.Body.Close()

  log.Println(req.RemoteAddr, " ", resp.Status)

  removeHopHeaders(resp.Header)
  removeConnectionHeaders(resp.Header)

  copyHeader(w.Header(), resp.Header)
  w.WriteHeader(resp.StatusCode)
  io.Copy(w, resp.Body)
}

func main() {
  var addr = flag.String("addr", "127.0.0.1:9999", "proxy address")
  flag.Parse()

  proxy := &forwardProxy{}

  log.Println("Starting proxy server on", *addr)
  if err := http.ListenAndServe(*addr, proxy); err != nil {
    log.Fatal("ListenAndServe:", err)
  }
}

In ServeHTTP, the handler logs the request, strips hop-by-hop headers, executes the request with an http.Client (the Request.URL already has the full absolute URL), strips hop-by-hop headers from the response, and copies the result back to the client.

With this proxy listening on its default port 9999:

$ go run basic-forward-proxy.go
2022/10/21 19:28:02 Starting proxy server on 127.0.0.1:9999

a client can point curl at it via the http_proxy environment variable:

$ http_proxy=http://localhost:9999 curl http://example.org
<!doctype html>
<html>
...
... // the rest of the document

The proxy logs each forwarded request and the target server responds normally.

This is a deliberately simple implementation. Production forward proxies need much more: proper header manipulation, security hardening against malicious requests, and robust handling of the many edge cases that real-world HTTP traffic presents. But the foundation is in place — the proxy forwards requests and logs activity, and it's straightforward to extend with domain filtering or other policy logic.

Making an HTTP Client Go Through a Proxy

The previous sections covered proxy servers from the server side. Now let's flip to the client side and see how Go programs issue HTTP requests through a proxy. Consider this simple client, which sends a GET to a URL and prints the response status and body:

func main() {
  target := flag.String("target", "http://example.org", "URL to get")
  flag.Parse()

  resp, err := http.Get(*target)
  if err != nil {
    log.Fatal(err)
  }
  defer resp.Body.Close()

  fmt.Println("Response status:", resp.Status)
  body, err := ioutil.ReadAll(resp.Body)
  if err != nil {
    log.Fatal(err)
  }
  fmt.Println(string(body))
}

Running it without any special setup:

$ go run http-get-basic.go --target http://example.org
Response status: 200 OK
<!doctype html>
<html>
...
... // the rest of the document

Just like curl, Go's HTTP stack respects the http_proxy environment variable. With the forward proxy from earlier running on port 9999, we can invoke the client this way:

$ http_proxy=localhost:9999 go run http-get-basic.go --target http://example.org
Response status: 200 OK
<!doctype html>
<html>
...
... // the rest of the document

The proxy logs confirm the request is being routed through it:

2022/10/22 06:47:45 GET http://example.org/ HTTP/1.1
Accept-Encoding: gzip
User-Agent: Go-http-client/1.1

Go's HTTP machinery also checks https_proxy for HTTPS traffic, the uppercase forms HTTP_PROXY and HTTPS_PROXY, and no_proxy / NO_PROXY for domain-based exclusions. Though these environment variables aren't covered by any formal industry standard, they are very widely supported across tools. The Go standard library's handling of these variables lives in the x/net/http/httpproxy package.

A Gotcha with localhost

There's a common pitfall when developing against local backend servers: Go HTTP clients refuse to proxy localhost addresses by default, even when http_proxy is set. Point the client at a debugging server on port 8080 with the proxy configured:

$ http_proxy=localhost:9999 go run http-get-basic.go --target http://localhost:8080/foo/bar

The client gets the response, but directly from the server — not via the proxy. Check the proxy logs and you'll see nothing new. This default behavior is inconvenient for local testing, but there are two easy ways around it.

Workaround 1: network config. The simplest fix doesn't touch any Go code. On Linux, just add an alias entry in /etc/hosts:

127.0.0.1 local.alias

Now requests to that alias do go through the proxy:

$ http_proxy=localhost:9999 go run http-get-basic.go --target http://local.alias:8080/foo/bar

Workaround 2: custom transport. Alternatively, keep the machine config unchanged and adjust how you initialize the HTTP client. The default http.Transport calls ProxyFromEnvironment for proxy settings — this function reads the environment variables and contains the localhost special case. Replacing it with an explicit proxy configuration bypasses that logic:

func main() {
  target := flag.String("target", "http://example.org", "URL to get")
  proxy := flag.String("proxy", "http://localhost:9999", "proxy to use")
  flag.Parse()

  proxyUrl, err := url.Parse(*proxy)
  if err != nil {
    log.Fatal(err)
  }
  client := &http.Client{
    Transport: &http.Transport{Proxy: http.ProxyURL(proxyUrl)},
  }

  r, err := client.Get(*target)
  if err != nil {
    log.Fatal(err)
  }
  defer r.Body.Close()
  body, err := ioutil.ReadAll(r.Body)
  if err != nil {
    log.Fatal(err)
  }
  fmt.Println(string(body))
}

With the proxy on port 9999 and the server on 8080, this client routes through the proxy successfully, which you can confirm in the proxy logs:

$ go run http-get-explicit-proxy.go --target http://localhost:8080/foo/bar
hello /foo/bar

Appendix A: A Note on Host Headers

You may have noticed Host: headers appearing in the debug output and curl requests throughout this post. The Host header was introduced to support virtual hosting: multiple domains resolving to one IP address. When a server receives a request with a relative URL, the Host header is what tells it which domain the request targets.

A Host header field must be sent in all HTTP/1.1 request messages. A 400 (Bad Request) status code may be sent to any HTTP/1.1 request message that lacks or contains more than one Host header field.

Go's http package handles Host specially. On the server side, the header's value is promoted to the http.Request.Host field and removed from the http.Request.Header map; on the client side, it's set automatically from the URL that was passed in.

Appendix B: A Reverse Proxy Acting as a Forward Proxy

The line between forward and reverse proxies can seem blurry, which is understandable — at the core, both simply accept requests and forward them elsewhere. Here's an illustration: code that leverages httputil.ReverseProxy to behave as a forward proxy:

func proxyHandler(w http.ResponseWriter, r *http.Request) {
  target, err := url.Parse(r.URL.Scheme + "://" + r.URL.Host)
  if err != nil {
    log.Fatal(err)
  }

  reqb, err := httputil.DumpRequest(r, true)
  if err != nil {
    log.Fatal(err)
  }
  log.Println(string(reqb))

  p := httputil.NewSingleHostReverseProxy(target)
  p.ServeHTTP(w, r)
}

func main() {
  var addr = flag.String("addr", "127.0.0.1:9999", "proxy address")
  flag.Parse()

  http.HandleFunc("/", proxyHandler)
  log.Println("Starting proxy server on", *addr)
  if err := http.ListenAndServe(*addr, nil); err != nil {
    log.Fatal("ListenAndServe:", err)
  }
}

The code reads the target URL from the absolute path of the incoming request, then hands that to NewSingleHostReverseProxy, which proxies the request to the intended destination.

This example is left as an exercise — you now have all the pieces needed to unpack exactly how it operates.