Building a reverse proxy in Go: best practices explained
Reverse proxies sit at the edge of your infrastructure, forwarding client requests to upstream services while handling authentication, rate limiting, request rewriting, and error handling. Go's standard library makes this straightforward with httputil.ReverseProxy, yet most teams either reinvent the wheel or miss critical production patterns. In this post, we'll walk through building a reverse proxy in Go that handles real-world scenarios: path rewriting, custom headers, graceful error handling, and monitoring.
httputil.ReverseProxy — Forward requests to an upstream with a few lines
The simplest reverse proxy in Go takes about five lines of code. Go's net/http/httputil package provides NewSingleHostReverseProxy, which creates a handler that forwards HTTP requests to a target URL. This is powerful because it handles the heavy lifting: copying response headers, managing body streams, and dealing with HTTP semantics you'd otherwise implement manually.
package main
import (
"log"
"net/http"
"net/url"
"net/http/httputil"
)
func main() {
target, err := url.Parse("http://localhost:3000")
if err != nil {
log.Fatal(err)
}
proxy := httputil.NewSingleHostReverseProxy(target)
log.Fatal(http.ListenAndServe(":8080", proxy))
}With this, every request to http://localhost:8080/api/users gets forwarded to http://localhost:3000/api/users. Requests and responses flow through unchanged. The proxy handles connection pooling, timeout defaults, and response streaming transparently.
In production, you'll rarely stop here. You need observability into what's happening, control over which requests pass through, and resilience when the upstream fails. Let's build up from this foundation.
Modify requests — Rewrite headers and paths in the Rewrite function
Request modification is where reverse proxies earn their place in your stack. You might need to strip authentication headers before sending to an upstream, inject tracing IDs, rewrite paths, or add tenant context. The ReverseProxy type exposes a Rewrite field (added in Go 1.20) that lets you intercept and modify requests before forwarding.
Here's a practical example: you're running a microservice architecture where the auth service validates tokens, then forwards to your main API. The auth service needs to strip the Authorization header before passing to the upstream (since it's already been validated) and add an X-User-ID header with the authenticated user.
package main
import (
"log"
"net/http"
"net/url"
"net/http/httputil"
"strings"
)
func main() {
target, err := url.Parse("http://localhost:3000")
if err != nil {
log.Fatal(err)
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.Rewrite = func(r *httputil.ProxyRequest) {
// Strip sensitive headers
r.Out.Header.Del("Authorization")
// Add authenticated context
// (in real code, extract from request context or token)
r.Out.Header.Set("X-User-ID", "user-123")
r.Out.Header.Set("X-Forwarded-For", r.In.RemoteAddr)
// Rewrite path: /api/v2/users -> /users
if strings.HasPrefix(r.Out.URL.Path, "/api/v2") {
r.Out.URL.Path = strings.TrimPrefix(r.Out.URL.Path, "/api/v2")
}
}
log.Fatal(http.ListenAndServe(":8080", proxy))
}The ProxyRequest gives you access to both the incoming request (r.In) and the outgoing request to the target (r.Out). This separation is crucial: you can inspect the original request, make decisions, then modify only what the upstream sees. Common patterns include:
- Header injection: Add request IDs, trace headers, or authentication tokens.
- Path rewriting: Convert versioned API paths to internal routes.
- Query parameter manipulation: Filter, add, or remove query parameters.
- Host rewriting: Change the
Hostheader to match the upstream's expectations.
For complex rewriting logic, keep the Rewrite function focused and move business logic to helper functions. This keeps your proxy readable and testable.
Handle errors — ErrorHandler returns friendly responses
When an upstream service is down or responds slowly, a naive reverse proxy either hangs or returns a cryptic error. The ErrorHandler field on ReverseProxy lets you intercept these failures and respond gracefully. This is where you surface observability: log the failure, increment metrics, and return a user-friendly response.
package main
import (
"encoding/json"
"log"
"net/http"
"net/url"
"net/http/httputil"
"time"
)
func main() {
target, err := url.Parse("http://localhost:3000")
if err != nil {
log.Fatal(err)
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("Upstream error: %v (path: %s, method: %s)",
err, r.URL.Path, r.Method)
// Return a structured error response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Service temporarily unavailable",
"timestamp": time.Now().Unix(),
"request_id": r.Header.Get("X-Request-ID"),
})
}
log.Fatal(http.ListenAndServe(":8080", proxy))
}The error handler is called when the proxy can't connect to the upstream, the upstream times out, or the upstream closes the connection unexpectedly. You should:
- Log errors with context: Include the path, method, target URL, and error type so you can diagnose issues.
- Increment failure metrics: Track which upstreams are failing and how often.
- Return appropriate status codes: Use
502 Bad Gatewayfor upstream unavailability,504 Gateway Timeoutfor timeouts. - Preserve request IDs: Echo back any request ID so clients can correlate their logs with yours.
If you're running multiple upstreams, consider implementing retry logic in the error handler. The standard library's httputil.ReverseProxy doesn't retry by default, so you'll need to manage that yourself or use a load balancer like Envoy or HAProxy in front of your proxy.
Use cases — Gateways, tunnels and local development tools
Reverse proxies solve several real production problems. Understanding where they fit helps you avoid over-engineering.
API Gateways
In a microservices setup, a reverse proxy sits at the entry point, forwarding requests to service A for /users and service B for /orders. This centralizes authentication, rate limiting, and request logging. You'd modify the proxy to route based on path prefixes:
package main
import (
"log"
"net/http"
"net/url"
"net/http/httputil"
"strings"
)
func routeRequest(w http.ResponseWriter, r *http.Request) {
var target *url.URL
var err error
if strings.HasPrefix(r.URL.Path, "/users") {
target, _ = url.Parse("http://user-service:3001")
} else if strings.HasPrefix(r.URL.Path, "/orders") {
target, _ = url.Parse("http://order-service:3002")
} else {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.ServeHTTP(w, r)
}
func main() {
log.Fatal(http.ListenAndServe(":8080", http.HandlerFunc(routeRequest)))
}Local development tools
A reverse proxy on your development machine can tunnel requests to a staging environment or expose a locally-running service through a public URL. Tools like ngrok do this, but a custom proxy gives you control. Developers often run multiple services locally and use a proxy to route http://localhost/api to one service and http://localhost/admin to another.
Security and compliance
A reverse proxy can enforce TLS, validate API keys, inject security headers, or redact sensitive data from logs. You might use it to strip internal error messages before returning to clients, preventing information leakage.
Common mistakes
Not setting timeouts: The default ReverseProxy inherits timeouts from the http client, which can be generous. Always set explicit timeouts to avoid hanging requests:
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.Transport = &http.Transport{
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
IdleConnTimeout: 30 * time.Second,
}Forgetting to handle hop-by-hop headers: Headers like Connection, Keep-Alive, and Transfer-Encoding are meant only for direct connections and should not be forwarded. The standard library handles this, but if you're manually copying headers, you'll break compatibility.
Losing request context: If you modify requests or add headers, ensure you preserve crucial context like the original host, request path, and client IP. Missing X-Forwarded-For causes upstream services to think all requests come from your proxy's IP.
No observability: A proxy without logging and metrics is a black box. At minimum, log every request with the upstream response time and status code. Use structured logging so you can grep and aggregate later.
Buffering entire response bodies: If your proxy copies response bodies into memory before sending to the client, you'll run into memory issues with large files. The standard library streams responses by default, which is the right approach.
Key takeaways
httputil.ReverseProxyis production-ready for forwarding requests; start simple and add features as needed.- Use the
Rewritefunction to inject headers, rewrite paths, and add authenticated context before requests reach upstreams. - Implement an
ErrorHandlerto log failures, return structured error responses, and surface issues to monitoring. - Set explicit timeouts on the underlying transport to prevent hanging requests and cascading failures.
- Reverse proxies solve real problems in gateways, local development, and security enforcement; use them where they simplify your architecture.
- Monitor your proxy's behavior: log requests, track latency, and alert on error rates so you catch upstream issues early.






Comments
Loading comments…