How to Build a Proxy Server in Rust

Web
Build a Rust proxy server by creating a binary crate with tokio and hyper dependencies to forward HTTP requests asynchronously.

When you need to intercept traffic

You are debugging a flaky API call. The frontend works. The backend works. Something breaks in the middle. You need to intercept the traffic, peek at the headers, maybe modify a payload, and pass it along. A proxy server sits between the client and the target, forwarding requests and responses. Building one in Rust gives you full control over the wire protocol with zero runtime overhead. You get the safety of the type system and the speed of async I/O.

The proxy as a service pipeline

A proxy is a middleman. It receives a request, decides what to do, and usually forwards it to the real server. It takes the response and sends it back. Think of a relay runner. The baton is the HTTP request. The runner grabs it, runs to the next station, hands it off, waits for the result, and runs back.

In Rust, we use async I/O to handle thousands of these runners simultaneously without spawning a thread for each one. The hyper crate provides the HTTP machinery. It implements the Service trait, which is the standard interface for async request-response cycles. Your proxy becomes a composition of services. The server service accepts connections. The connection service handles requests. The client service forwards them.

The proxy is just a service that calls another service. Compose them and you have a pipeline.

Minimal example

This example creates a proxy that listens on localhost:8080 and forwards every request to http://example.com. It strips the body and headers for simplicity. Copy this into a new binary crate.

use hyper::{Body, Request, Response, Server};
use hyper::service::{make_service_fn, service_fn};
use std::net::SocketAddr;

/// A minimal HTTP proxy that forwards requests to a hardcoded target.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Bind to localhost port 8080.
    // The ? operator propagates parse errors to the caller.
    let addr: SocketAddr = "127.0.0.1:8080".parse()?;
    println!("Listening on http://{}", addr);

    // Create a service factory.
    // This closure runs once per incoming connection.
    let make_svc = make_service_fn(|_conn| async {
        // Return a service that handles individual requests on this connection.
        Ok::<_, hyper::Error>(service_fn(|req: Request<Body>| async move {
            // Build a new client for each request.
            // In production, reuse the client to save resources.
            let client = hyper::Client::new();

            // Construct the target URL by swapping the host.
            // We keep the path and query string from the original request.
            let target_url = format!("http://example.com{}", req.uri().path());

            // Rebuild the request with the new URI.
            // We clone the method because it is borrowed from the original request.
            let proxied_req = Request::builder()
                .method(req.method().clone())
                .uri(&target_url)
                .body(Body::empty())
                .unwrap();

            // Forward the request and return the response.
            // The .await yields control to the runtime while waiting for the network.
            client.request(proxied_req).await
        }))
    });

    // Start the server and wait for shutdown.
    // The ? operator propagates server errors.
    Server::bind(&addr).serve(make_svc).await?;

    Ok(())
}

Add tokio = { version = "1", features = ["full"] } and hyper = { version = "0.14", features = ["full"] } to your Cargo.toml. Run the code and point your browser to http://127.0.0.1:8080. You will see the target site, but the URL bar stays on your proxy.

Run this and point your browser to localhost:8080. You'll see the target site, but the URL bar stays on your proxy.

What happens under the hood

The #[tokio::main] macro sets up the async runtime. Rust does not have a built-in async executor. You need one to drive the futures. Tokio provides a thread pool and an event loop. The runtime schedules tasks on worker threads and monitors I/O events.

Server::bind creates a listener on the socket. When a connection arrives, make_service_fn runs. This closure executes once per connection. It returns a Service that handles requests on that connection. HTTP/1.1 keeps connections alive. One connection can carry many requests. The service per connection handles the pipeline of requests.

Inside, service_fn wraps the request handler. The handler is an async closure. It captures the request, builds a new request for the target, and awaits the response. The .await keyword is crucial. It tells the runtime to pause this task and run other tasks while waiting for the network. When the response arrives, the task resumes and returns the response to the server. The server sends it back to the client.

The Body type is a stream. It yields chunks of data. The proxy can forward the body stream directly. This avoids buffering the entire payload in memory. The runtime handles backpressure automatically. If the target server is slow, the proxy pauses reading from the client. If the client is slow, the proxy pauses writing to the target.

Realistic example

The minimal example creates a client per request. This is inefficient. Each client opens a new TCP connection. A realistic proxy reuses the client. It also forwards the body and headers.

use hyper::{Body, Request, Response, Server};
use hyper::service::{make_service_fn, service_fn};
use std::net::SocketAddr;

/// A proxy that reuses the HTTP client and forwards request bodies.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr: SocketAddr = "127.0.0.1:8080".parse()?;
    println!("Listening on http://{}", addr);

    // Create the client once and share it across requests.
    // This avoids opening a new TCP connection for every proxy hop.
    let client = hyper::Client::new();

    let make_svc = make_service_fn(|_conn| {
        // Clone the client for each connection handler.
        // Cloning is cheap because the client uses an internal Arc.
        let client = client.clone();
        async move {
            Ok::<_, hyper::Error>(service_fn(move |req: Request<Body>| {
                // Clone again for each request.
                // The closure captures the client by value.
                let client = client.clone();
                async move {
                    let target_url = format!("http://example.com{}", req.uri().path());

                    // Forward the method and URI.
                    // The body is forwarded by passing `req` directly.
                    // This moves the body stream into the new request.
                    let proxied_req = Request::builder()
                        .method(req.method().clone())
                        .uri(&target_url)
                        .body(req.into_body())
                        .unwrap();

                    client.request(proxied_req).await
                }
            }))
        }
    });

    Server::bind(&addr).serve(make_svc).await?;
    Ok(())
}

The hyper::Client implements Clone. Cloning it is cheap. It shares the internal connection pool via an Arc. You can clone the client and share it across all requests. The service factory clones the client once per connection. The request handler clones it once per request. This pattern keeps the client alive for the duration of the server.

Reuse the client. Creating a new one per request burns file descriptors and CPU cycles.

Pitfalls and compiler errors

If you try to use req after moving it into the builder, the compiler stops you with E0382 (use of moved value). The request body is a stream. You can only consume it once. If you forget to forward the body, the target server receives an empty payload. POST and PUT requests will fail silently or return 400 errors.

The Request::builder returns a Result. Calling .unwrap() panics if the URI is invalid. In a proxy, you should handle the error or ensure the URI construction is safe. The format! macro creates a valid URI string, but if you manipulate the path manually, you might introduce invalid characters.

Headers matter. If you don't forward the Host header, the target server might return the wrong content or reject the request. Some servers rely on Host for virtual hosting. The minimal example drops headers. A production proxy must copy headers or filter them intentionally.

Forward the body or your POST requests vanish into thin air.

Convention asides

The community convention for hyper::Client is to call .clone() freely. The client holds an internal Arc, so cloning is a pointer bump, not a deep copy. Writing client.clone() is idiomatic and clear. Avoid wrapping the client in an extra Arc unless you are sharing it across threads manually. The service factory handles the cloning for you.

Another convention is to keep unsafe blocks out of HTTP code. hyper and tokio are safe abstractions. You rarely need unsafe for a proxy. If you are doing low-level socket manipulation, wrap it in a safe API. The community calls this the "minimum unsafe surface" rule.

Decision matrix

Use hyper when you need low-level control over the HTTP protocol and want to build the proxy logic from scratch. Use reqwest when you just need to forward requests and don't care about the server-side binding. It handles TLS and redirects automatically. Use a framework like axum when your proxy needs complex routing, middleware, or integration with a larger web application. Use tokio directly when you are building a custom TCP proxy that operates below the HTTP layer.

Pick the tool that matches your complexity. Don't build a proxy from scratch if a library does the job.

Where to go next