Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Part 4: TLS — Encrypting Both Sides of the Proxy

Your proxy works. It forwards requests. But right now, clients connect over plain HTTP — anyone on the network can read the traffic. In production, you need encryption. Clients should connect to your proxy over HTTPS, and your proxy should validate the certificates it sees when connecting to upstreams.

TLS is one of those things that sounds simple — “encrypt the connection” — but has a hundred details: which certificates to present, whether to verify the upstream’s identity, how to handle certificate rotation, what to do when the client presents its own certificate. Pingora handles the mechanics — the handshake, the encryption, the protocol negotiation. Your job is to decide what to trust.

This part covers two directions of TLS:

  • Downstream TLS — clients connect to your proxy over HTTPS. You present a certificate.
  • Upstream TLS — your proxy connects to backends over HTTPS. You verify their certificate.

They’re separate concerns with separate APIs. Let’s walk through both.

Downstream TLS: Accepting HTTPS

In Parts 1–3, we used service.add_tcp() to listen for plain HTTP connections. For HTTPS, you swap in add_tls():

#![allow(unused)]
fn main() {
// Before: plain HTTP
service.add_tcp("0.0.0.0:6188");

// After: HTTPS with a certificate
service.add_tls("0.0.0.0:6443", "cert.pem", "key.pem")?;
}

That’s it. Three arguments:

  1. The address — which port to listen on
  2. Certificate path — the PEM file containing your server certificate (and any intermediate certs)
  3. Key path — the PEM file containing your private key

Pingora reads the files, configures the TLS acceptor, and from that point on every connection on that port goes through a TLS handshake before any HTTP bytes are exchanged.

Where Do You Get a Certificate?

For development, generate a self-signed one:

openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
    -days 365 -nodes -subj '/CN=localhost'

This creates cert.pem and key.pem in the current directory. The -nodes flag skips passphrase encryption (fine for dev, bad for production). The CN=localhost means the certificate is valid for the hostname localhost.

For production, you’d use Let’s Encrypt, your organization’s PKI, or a certificate manager like cert-manager.

What About HTTP/2 and Custom TLS Settings?

The add_tls() API uses sensible defaults. If you need more control — HTTP/2 support, mutual TLS, or custom cipher suites — Pingora provides add_tls_with_settings() which takes a TlsSettings object. TlsSettings lives in pingora-core (not re-exported through the pingora convenience crate), so you’d add pingora-core as a direct dependency:

#![allow(unused)]
fn main() {
use pingora_core::listeners::TlsSettings;

let mut tls = TlsSettings::intermediate("cert.pem", "key.pem")?;
tls.enable_h2(); // advertise HTTP/2 during ALPN negotiation
service.add_tls_with_settings("0.0.0.0:6443", None, tls);
}

TlsSettings::intermediate() follows Mozilla’s intermediate TLS recommendations — a reasonable default for most deployments. The enable_h2() method sets ALPN to prefer HTTP/2 while still allowing HTTP/1.1.

For the tutorial, we’ll stick with the simple add_tls() API. The important thing is understanding the concepts — the API details vary by TLS backend (BoringSSL, rustls, s2n) and Pingora version.

Upstream TLS: Connecting to Backends

We’ve been using upstream TLS since Part 1 — HttpPeer::new(("1.1.1.1", 443), true, "one.one.one.one"). The true means “connect over TLS.” The SNI string tells the upstream server which certificate to present.

But there’s more to upstream TLS than turning it on. The PeerOptions on an HttpPeer give you fine-grained control over certificate verification:

OptionDefaultWhat It Controls
verify_certtrueCheck that the upstream’s certificate is signed by a trusted CA
verify_hostnametrueCheck that the certificate’s CN matches the SNI
use_system_certstrueLoad the OS trust store for verification
caNoneCustom CA certificates (instead of system trust store)
alternative_cnNoneAccept a different hostname in the cert

The defaults are secure. But there are legitimate reasons to change them:

verify_cert: false — useful in development when the upstream has a self-signed cert. Never do this in production.

Custom CA — if your organization runs its own certificate authority (common in internal networks), you’d load the CA cert and pass it via ca:

#![allow(unused)]
fn main() {
let peer = Box::new(HttpPeer::new(("internal.api", 443), true, "internal.api".to_string()));
peer.options.ca = Some(my_ca_certs);
}

alternative_cn — if the upstream’s certificate has a different hostname than what you’re connecting to (e.g., connecting by IP but the cert has a domain name), you can specify what CN to accept:

#![allow(unused)]
fn main() {
peer.options.alternative_cn = Some("api.example.com".to_string());
}

Mutual TLS: When the Client Presents a Certificate

So far, TLS is one-way: the server proves its identity to the client. But some setups require the client to prove its identity too — this is mutual TLS (mTLS). It’s common in zero-trust networks and service mesh architectures.

In mTLS, the proxy (as a TLS server) asks the connecting client for a certificate. If the client can’t present a valid one, the connection is rejected.

With Pingora’s TlsSettings, you configure mTLS through a client certificate verifier:

#![allow(unused)]
fn main() {
// This is a simplified example — the actual verifier implementation
// depends on your PKI setup and TLS backend (rustls, BoringSSL, s2n).
let mut tls = TlsSettings::intermediate("cert.pem", "key.pem")?;
tls.set_client_cert_verifier(my_verifier);
}

The verifier is responsible for checking that the client’s certificate is valid — signed by a trusted CA, not expired, not revoked. This is where your specific trust model lives.

mTLS on the Upstream Side

If your proxy needs to present a client certificate when connecting to an upstream (i.e., the upstream requires mTLS), you set client_cert_key on the HttpPeer:

#![allow(unused)]
fn main() {
use pingora::utils::CertKey;
use std::sync::Arc;

let client_cert = Arc::new(CertKey::from_pem_file("client-cert.pem", "client-key.pem")?);
let peer = Box::new(HttpPeer::new(("api.internal", 443), true, "api.internal".to_string()));
peer.options.client_cert_key = Some(client_cert);
}

This is common when your proxy is one service in a service mesh, and the upstream requires all callers to authenticate.

The Full Picture

Here’s what TLS looks like in both directions:

Client                     Your Proxy                    Upstream
  │                           │                            │
  │──── TLS handshake ──────►│                            │
  │  (client validates       │                            │
  │   your proxy's cert)     │                            │
  │                           │──── TLS handshake ──────►│
  │                           │  (proxy validates         │
  │                           │   upstream's cert)        │
  │                           │                            │
  │  [optional: mTLS]        │  [optional: mTLS]         │
  │  client presents cert    │  proxy presents cert       │
  │  to proxy                │  to upstream               │
  │                           │                            │
  │◄──── encrypted data ────►│◄──── encrypted data ─────►│

The two TLS sessions are independent. The client might use TLS 1.3 with a modern cipher suite while the upstream connection uses TLS 1.2 with a different suite. Pingora terminates the client’s TLS session, reads the HTTP request, then opens a separate TLS session to the upstream.

This is called TLS termination — the proxy is the endpoint for both TLS sessions, with unencrypted HTTP in the middle. This is what lets you inspect and modify requests in filters (Part 3). If you needed end-to-end encryption without the proxy seeing the plaintext, you’d use TLS passthrough — but that means no filters, no routing, no modification. It’s a tradeoff.

The Code

Let’s put it together. We’ll take the load balancer from Part 2, add downstream TLS (accept HTTPS on port 6443), and keep the existing HTTP listener on port 6188 for comparison.

use async_trait::async_trait;
use pingora::prelude::*;
use pingora::proxy::{ProxyHttp, Session};
use pingora::upstreams::peer::HttpPeer;
use pingora::lb::{LoadBalancer, selection::RoundRobin};
use std::sync::Arc;

pub struct LB(Arc<LoadBalancer<RoundRobin>>);

#[async_trait]
impl ProxyHttp for LB {
    type CTX = ();
    fn new_ctx(&self) -> Self::CTX {}

    async fn upstream_peer(
        &self,
        _session: &mut Session,
        _ctx: &mut Self::CTX,
    ) -> Result<Box<HttpPeer>> {
        let upstream = self.0
            .select(b"", 256)
            .ok_or_else(|| Error::new_str("no healthy upstream available"))?;
        let peer = Box::new(HttpPeer::new(
            upstream,
            true, // upstream TLS
            "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 upstreams = LoadBalancer::try_from_iter(["1.1.1.1:443", "1.0.0.1:443"]).unwrap();
    let lb = LB(Arc::new(upstreams));

    let mut service = http_proxy_service(&server.configuration, lb);

    // Plain HTTP (for comparison/testing)
    service.add_tcp("0.0.0.0:6188");

    // HTTPS with a certificate
    service.add_tls("0.0.0.0:6443", "cert.pem", "key.pem").unwrap();

    server.add_service(service);
    server.run_forever();
}

The key changes from Part 2:

  1. add_tls() replaces one of the add_tcp() calls — the proxy now accepts HTTPS on port 6443
  2. The ProxyHttp implementation is unchanged — TLS is a listener concern, not a proxy logic concern
  3. The upstream connection (HttpPeer::new(..., true, ...)) still uses TLS as before

Running It

First, generate the self-signed certificate:

openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem \
    -days 365 -nodes -subj '/CN=localhost'

Then run the proxy:

cargo run

Test the HTTPS endpoint (note the --insecure flag — curl won’t trust our self-signed cert):

curl https://localhost:6443 --insecure -sv

You should see the connection use TLS, and the response from 1.1.1.1.

For comparison, the HTTP endpoint still works:

curl http://localhost:6188 -sv

What Just Happened

Let’s be clear about what Pingora handled:

  1. TLS handshake — Pingora performed the full TLS handshake with the client, presenting your certificate and negotiating cipher suites. You didn’t write any of that code.

  2. Certificate loadingadd_tls() read the PEM files and configured the TLS stack. If the files were missing or malformed, it would fail at startup — not at request time.

  3. Separate listeners — The same proxy serves both HTTP and HTTPS. This is useful during migration (HTTP → HTTPS) and for health checks that don’t need encryption.

What you’re responsible for:

  1. Certificate management — Getting valid certificates, rotating them before they expire, protecting the private key. Pingora reads the files at startup; it doesn’t manage the certificates for you.

  2. Trust decisions — Whether to verify upstream certs, which CAs to trust, whether to accept self-signed certs. The defaults are secure, but you need to understand what you’re changing when you override them.

  3. Key protection — The private key file must be readable by the process but protected from everyone else. In production, use filesystem permissions, secrets managers, or HSMs.

Common Mistakes

Forgetting the SNI. The third argument to HttpPeer::new() is the SNI hostname. If you pass an IP address instead of a hostname, the upstream server won’t know which certificate to present, and the TLS handshake fails. This is why our example uses "one.one.one.one" even though we connect to 1.1.1.1.

Using self-signed certs in production. verify_cert: false makes the handshake succeed but defeats the purpose of TLS. Anyone can intercept the connection and present a fake certificate. Use proper certificates from a trusted CA.

Certificate expiry. Pingora reads certificates at startup. If your certificate expires while the proxy is running, TLS connections will fail. Use certificate rotation (reload the cert without restarting) or automation (Let’s Encrypt with short-lived certs).

What’s Next

Your proxy now accepts encrypted connections and verifies upstream certificates. But it’s still a single process — if it crashes or you need to update the code, every connection drops. In Part 5: Production Operations, we’ll cover graceful restarts, configuration files, and zero-downtime deployment.