ARC2 Proxy
Rust Programming Language · By Milen Denev

Building a Reverse Proxy in Rust with Axum 0.7, Tower-HTTP and rustls

A hands-on guide to the building blocks of a real reverse proxy in Axum 0.7: serving static files with ServeDir and nest_service, forwarding requests with a hyper HTTP client, terminating TLS with rustls 0.23 and axum-server, and extracting the client's real address with into_make_service_with_connect_info.

Building a Reverse Proxy in Rust with Axum 0.7, Tower-HTTP and rustls

Introduction

Axum has quickly become one of the most popular ways to build web services in Rust. It is built on top of hyper and tower, runs on the Tokio async runtime, and gives you an ergonomic router with type-safe extractors while staying close enough to the metal to be genuinely fast. In this article we build up, piece by piece, the components you need to run a real reverse proxy in Axum 0.7: serving static files with ServeDir and nest_service, forwarding requests to an upstream with an HTTP client, terminating TLS with rustls (0.23) and axum-server, and — a detail that trips up almost everyone — extracting the client's real socket address using into_make_service_with_connect_info.

These are exactly the pieces that sit at the heart of ARC2 Proxy, and they are also the topics people search for most when they start doing serious network programming with Axum. By the end you will have a mental model for how they fit together and working snippets for each.

Setting up the project

Here is a dependency set that covers everything in this article:

[dependencies]
axum = "0.7"
axum-server = { version = "0.7", features = ["tls-rustls"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace"] }
hyper = { version = "1", features = ["full"] }
hyper-util = { version = "0.1", features = ["client-legacy", "http1"] }
http-body-util = "0.1"
rustls = "0.23"
rustls-pemfile = "2"

A note on versions, since this is a frequent source of confusion: Axum 0.7 rides on hyper 1.0 and http 1.0, which changed several types compared to the 0.6 era. In particular, axum::Server was removed — the old axum::Server::bind(...) pattern is gone. In 0.7 you either use the free function axum::serve for plain HTTP, or a server crate like axum-server when you need TLS. We will use both.

Serving static files with ServeDir

A reverse proxy often needs to serve some things itself — a static front-end, error pages, a health endpoint — while forwarding everything else. The tower-http crate provides ServeDir, a ready-made service that serves files from a directory, and Axum lets you mount any tower service into the router with nest_service (or route_service for a single path).

use axum::Router;
use tower_http::services::ServeDir;

fn app() -> Router {
    // Everything under /assets is served from the ./static directory on disk.
    Router::new()
        .nest_service("/assets", ServeDir::new("static"))
}

nest_service strips the matched prefix before handing the request to the inner service, so a request for /assets/css/app.css is served from static/css/app.css. If you want to serve files at the router root and fall back to an index.html for single-page apps, combine ServeDir with a fallback:

use axum::Router;
use tower_http::services::ServeDir;

fn spa() -> Router {
    let serve = ServeDir::new("dist")
        .not_found_service(ServeDir::new("dist").append_index_html_on_directories(true));

    Router::new().fallback_service(serve)
}

ServeDir handles the fiddly parts for you: it sets Content-Type from the file extension, supports range requests (so video seeking and resumable downloads work), emits Last-Modified and ETag headers, and answers conditional requests with 304 Not Modified. For a static file server that is genuinely fast, this is most of the job done in a couple of lines — a good reminder of how much leverage the tower ecosystem gives you.

The reverse proxy handler

The core of a reverse proxy is simple to state: receive a request, rewrite it to point at the upstream, send it with an HTTP client, and stream the response back to the client. In Axum 0.7 the cleanest way to do this is a fallback handler that takes the whole Request and uses a hyper-based client to forward it.

First, build a client once and share it via router state. With hyper 1.0 the client lives in hyper-util:

use hyper_util::client::legacy::{connect::HttpConnector, Client};
use hyper_util::rt::TokioExecutor;
use axum::body::Body;

type ProxyClient = Client<HttpConnector, Body>;

fn build_client() -> ProxyClient {
    Client::builder(TokioExecutor::new()).build(HttpConnector::new())
}

Now the handler. It rewrites the request URI to target the upstream host, forwards it, and returns the upstream response:

use axum::{
    body::Body,
    extract::State,
    http::{uri::Uri, Request},
    response::{IntoResponse, Response},
};

const UPSTREAM: &str = "127.0.0.1:8080";

async fn proxy(
    State(client): State<ProxyClient>,
    mut req: Request<Body>,
) -> Result<Response, axum::http::StatusCode> {
    // Preserve the original path and query, swap in the upstream authority.
    let path_and_query = req
        .uri()
        .path_and_query()
        .map(|pq| pq.as_str())
        .unwrap_or("/");

    let new_uri = format!("http://{UPSTREAM}{path_and_query}");
    *req.uri_mut() = Uri::try_from(new_uri)
        .map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;

    match client.request(req).await {
        Ok(resp) => Ok(resp.into_response()),
        Err(_) => Err(axum::http::StatusCode::BAD_GATEWAY),
    }
}

Wire it in as the router fallback so it catches everything not handled by a more specific route:

use axum::Router;
use tower_http::services::ServeDir;

fn app(client: ProxyClient) -> Router {
    Router::new()
        .nest_service("/assets", ServeDir::new("static"))
        .fallback(proxy)
        .with_state(client)
}

A production proxy does more than this — it manages hop-by-hop headers (stripping Connection, Keep-Alive, Transfer-Encoding and friends), sets X-Forwarded-For and X-Forwarded-Proto, enforces timeouts, and pools connections per upstream. But the shape above is the real core, and it streams bodies rather than buffering them, so large responses do not blow up memory.

Terminating TLS with rustls and axum-server

Because axum::Server no longer exists in 0.7, TLS is handled by a dedicated server crate. The most common choice is axum-server with its tls-rustls feature, which wraps your router in a rustls-backed acceptor. rustls 0.23 is a modern, memory-safe TLS stack, and it needs a crypto provider installed before use.

The simplest path loads a certificate and key from PEM files:

use axum_server::tls_rustls::RustlsConfig;
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    // Install a process-wide crypto provider for rustls 0.23.
    rustls::crypto::ring::default_provider()
        .install_default()
        .expect("install rustls crypto provider");

    let client = build_client();
    let router = app(client);

    let tls = RustlsConfig::from_pem_file("cert.pem", "key.pem")
        .await
        .expect("load TLS cert/key");

    let addr = SocketAddr::from(([0, 0, 0, 0], 443));
    axum_server::bind_rustls(addr, tls)
        .serve(router.into_make_service())
        .await
        .unwrap();
}

If you need per-domain certificates — one server answering for many hosts — you build a rustls ServerConfig with a custom certificate resolver keyed on the SNI hostname from the TLS ClientHello. That is a deeper topic (we cover it in our dedicated SNI article), but the entry point is the same: construct a ServerConfig, wrap it, and hand it to the acceptor. The key point for anyone searching for rustls 0.23 and ServerConfig is that the builder API is staged — you pick the protocol versions, then client-auth policy, then the certificate resolver — and that you must install a crypto provider first or construction will fail at runtime.

Getting the client's real IP with connect info

Here is the detail that catches nearly everyone building a proxy: by default, an Axum handler cannot see the client's socket address. If you try to extract ConnectInfo<SocketAddr> without opting in, you get a runtime error, because the peer address is a property of the TCP connection, not the HTTP request — and Axum only threads it through if you ask it to. The mechanism is into_make_service_with_connect_info.

use axum::extract::ConnectInfo;
use std::net::SocketAddr;

async fn who_am_i(ConnectInfo(addr): ConnectInfo<SocketAddr>) -> String {
    format!("your address is {addr}")
}

For plain HTTP with axum::serve, you must use the connect-info variant of the make-service:

use axum::{routing::get, Router};
use std::net::SocketAddr;

#[tokio::main]
async fn main() {
    let app = Router::new().route("/ip", get(who_am_i));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:80").await.unwrap();

    // The magic: carry the peer SocketAddr into each request's extensions.
    axum::serve(
        listener,
        app.into_make_service_with_connect_info::<SocketAddr>(),
    )
    .await
    .unwrap();
}

With axum-server the same method exists on the router, so the TLS server uses it in exactly the same way:

axum_server::bind_rustls(addr, tls)
    .serve(router.into_make_service_with_connect_info::<SocketAddr>())
    .await
    .unwrap();

One important caveat for a reverse proxy: the socket address you get is the address of whatever connected to you. If your proxy sits behind another load balancer or CDN, that will be the upstream device, not the end user — in which case you read the real client IP from the X-Forwarded-For or Forwarded header instead, and only trust it from known upstreams. When your proxy is the true edge, ConnectInfo is the correct and authoritative source, which is why it powers things like our "what's my IP" and geolocation-aware tools.

Putting it together

The full skeleton of an HTTPS reverse proxy that also serves static assets and exposes the client IP:

use axum::{routing::get, Router};
use axum_server::tls_rustls::RustlsConfig;
use std::net::SocketAddr;
use tower_http::services::ServeDir;

#[tokio::main]
async fn main() {
    rustls::crypto::ring::default_provider()
        .install_default()
        .expect("install rustls crypto provider");

    let client = build_client();

    let router = Router::new()
        .route("/ip", get(who_am_i))
        .nest_service("/assets", ServeDir::new("static"))
        .fallback(proxy)
        .with_state(client);

    let tls = RustlsConfig::from_pem_file("cert.pem", "key.pem")
        .await
        .expect("load TLS cert/key");

    let addr = SocketAddr::from(([0, 0, 0, 0], 443));
    axum_server::bind_rustls(addr, tls)
        .serve(router.into_make_service_with_connect_info::<SocketAddr>())
        .await
        .unwrap();
}

Performance notes

A few things determine whether an Axum proxy is merely correct or genuinely fast:

  • Reuse the client. Build the hyper client once and share it through router state. It pools upstream connections, so you avoid a fresh TCP and TLS handshake on every forwarded request. Creating a client per request is the most common performance mistake.
  • Stream, do not buffer. Forward request and response bodies as streams. Buffering whole payloads into memory kills both latency and memory usage under load.
  • Let Tokio use your cores. The multi-threaded runtime (features = ["full"]) schedules tasks across a work-stealing thread pool, keeping all cores busy without manual sharding.
  • Cache what you can. The fastest upstream request is the one you never make. A concurrent in-memory cache (for example DashMap) in front of the proxy handler turns repeat requests into O(1) memory reads and is where a caching proxy earns most of its speed.
  • No garbage collector. Rust's ownership model means no GC pauses, so latency stays predictable in the tail — the property that matters most for a component sitting on every request's critical path.

Conclusion

Axum 0.7 gives you a remarkably clean foundation for network infrastructure: ServeDir and nest_service for static content, a hyper-based client for forwarding, axum-server plus rustls 0.23 for TLS, and into_make_service_with_connect_info for the client address. Each piece is small on its own, and the trick — as with most systems programming — is understanding how they compose and where the sharp edges are: the removal of axum::Server in 0.7, the required rustls crypto provider, and the fact that connect info must be opted into. Get those right and you have the skeleton of exactly the kind of fast, safe, predictable reverse proxy that ARC2 Proxy is built on.

Keep reading