Part 1: Your First Proxy
You need a proxy. Not a config file — an actual program you can modify, extend, and ship. Let’s build one.
About 40 lines of code. That’s all it takes to build a working reverse proxy that forwards HTTP requests to an upstream server. But those 40 lines contain the entire architecture of every Pingora service you’ll ever write.
The Request’s Journey
Before we write any code, let’s trace what happens when a client sends a request through a Pingora proxy:
1. Client sends: GET / HTTP/1.1 → :6188
2. Pingora accepts the connection
3. Pingora reads the request header
4. upstream_peer() is called → "send it to 1.1.1.1:443"
5. Pingora connects to 1.1.1.1:443 (or reuses an existing connection)
6. upstream_request_filter() is called → "add Host: one.one.one.one"
7. Pingora sends the request to the upstream
8. Pingora reads the upstream response
9. Pingora forwards the response to the client
10. Connection is recycled for reuse
Steps 2, 5, 7, 8, 9, and 10 are handled by the framework. You write steps 4 and 6. That’s the deal: Pingora handles the plumbing, you handle the logic.
The Smallest Possible Proxy
Every Pingora proxy has three pieces:
- A struct that implements
ProxyHttp— this is where your logic lives - A service that wraps your struct and listens on a port
- A server that hosts the service and manages the process
Here’s the complete code:
use async_trait::async_trait;
use pingora::prelude::*;
use pingora::proxy::{ProxyHttp, Session};
use pingora::upstreams::peer::HttpPeer;
pub struct MyProxy;
#[async_trait]
impl ProxyHttp for MyProxy {
type CTX = ();
fn new_ctx(&self) -> Self::CTX {}
async fn upstream_peer(
&self,
_session: &mut Session,
_ctx: &mut Self::CTX,
) -> Result<Box<HttpPeer>> {
let peer = Box::new(HttpPeer::new(
("1.1.1.1", 443),
true,
"one.one.one.one".to_string(),
));
Ok(peer)
}
async fn upstream_request_filter(
&self,
_session: &mut Session,
upstream_request: &mut pingora::http::RequestHeader,
_ctx: &mut Self::CTX,
) -> Result<()> {
upstream_request.insert_header("Host", "one.one.one.one")?;
Ok(())
}
}
fn main() {
let mut server = Server::new(None).unwrap();
server.bootstrap();
let mut service = http_proxy_service(&server.configuration, MyProxy);
service.add_tcp("0.0.0.0:6188");
server.add_service(service);
server.run_forever();
}
That’s it. 40 lines including imports. Let’s break down what each piece does.
upstream_peer() — Where Does the Request Go?
This is the only required method on ProxyHttp. It answers one question: where should this request be sent?
The return type is HttpPeer, which describes the upstream connection:
#![allow(unused)]
fn main() {
HttpPeer::new(
("1.1.1.1", 443), // The upstream address and port
true, // Use TLS?
"one.one.one.one".to_string(), // SNI hostname for TLS
)
}
The three parameters:
- Address — a hostname or IP with a port. This is where Pingora will connect.
- TLS — whether to encrypt the upstream connection. Since we’re connecting to an HTTPS server, this is
true. - SNI — Server Name Indication. During the TLS handshake, the client tells the server which hostname it’s looking for. Without the right SNI, the server won’t know which certificate to present.
Right now, every request goes to the same upstream. In Part 2, we’ll use this method to implement load balancing — selecting different backends for different requests.
upstream_request_filter() — Modifying the Request
This method is optional. We need it here because 1.1.1.1 requires a Host header to serve the right website. Without it, the server returns a 403.
#![allow(unused)]
fn main() {
async fn upstream_request_filter(
&self,
_session: &mut Session,
upstream_request: &mut pingora::http::RequestHeader,
_ctx: &mut Self::CTX,
) -> Result<()> {
upstream_request.insert_header("Host", "one.one.one.one")?;
Ok(())
}
}
The upstream_request parameter is mutable — you can add, remove, or modify any header. This is where you’d add authentication headers, strip internal headers, or rewrite the request path.
The method runs after the connection to the upstream is established but before the request is sent. This means you can make decisions based on the upstream connection (e.g., use different headers for different backends).
CTX — Per-Request State
The type CTX = () line looks odd. What’s it for?
Each request gets its own CTX instance. This is how you share state between the different phases of a single request. For example, you might parse a JWT in request_filter(), store the user ID in CTX, and use it in upstream_request_filter() to add an X-User-Id header.
Right now we don’t need per-request state, so we use () (the unit type — Rust’s way of saying “no data”). We’ll add real state in Part 3.
The Server
The main() function sets up three things:
fn main() {
// 1. Create the server
let mut server = Server::new(None).unwrap();
server.bootstrap();
// 2. Create a proxy service
let mut service = http_proxy_service(&server.configuration, MyProxy);
service.add_tcp("0.0.0.0:6188");
// 3. Register and run
server.add_service(service);
server.run_forever();
}
Server::new(None) — creates a Pingora server with default configuration. The None means “no custom configuration file.” In Part 5, we’ll pass a real config.
server.bootstrap() — initializes the server. This parses CLI arguments, sets up signal handlers, and prepares the runtime. It must be called before adding services.
http_proxy_service() — creates a service that uses our MyProxy handler. The first argument is the server configuration (needed for things like TLS settings). The second is our proxy struct.
service.add_tcp() — tells the service to listen on port 6188. You can add multiple listeners (TCP, Unix sockets, TLS).
server.run_forever() — starts the event loop. This spawns worker threads and blocks the main thread. The server will run until it receives a shutdown signal.
Running It
cargo run
Then in another terminal:
curl http://127.0.0.1:6188 -sv
You should see the 1.1.1.1 website, served through your proxy. The -sv flags show the request/response headers.
What Just Happened
Let’s look at what Pingora did for you, because it’s a lot:
- HTTP parsing — Pingora parsed the incoming HTTP/1.1 request from the client. No hand-written parser needed.
- Connection pooling — When you send a second request, Pingora reuses the existing connection to 1.1.1.1 instead of opening a new one. This is automatic.
- TLS — Pingora handled the entire TLS handshake with the upstream server. All you specified was
truefor “use TLS.” - Keep-alive — Both the downstream (client) and upstream connections are kept alive. No
Connection: closeneeded. - Error handling — If the upstream is unreachable, Pingora returns a 502 Bad Gateway to the client. You didn’t have to write that code.
- Concurrency — Multiple requests are handled concurrently using async I/O. No thread per connection.
That’s the value of a framework. You wrote two methods that answer “where” and “what headers.” Pingora handled the rest.
What’s Next
This proxy works, but it only talks to one backend. If that backend goes down, every request fails. In Part 2: Load Balancing, we’ll add multiple backends, select between them, and detect when one is unhealthy.