Routing multiple TLS backends through one port with nginx stream

Most of the time, when people talk about routing TLS traffic based on hostname, they mean virtual hosts terminating TLS at the edge and forwarding plaintext HTTP to a backend. That's fine for HTTP. It falls apart the moment the backend protocol isn't HTTP, or you don't want to hold the private key at the edge at all.

nginx's stream module solves a narrower, more useful problem: route a raw TCP connection to a backend based on the SNI hostname in the TLS ClientHello, without terminating TLS and without caring what's inside the tunnel afterward.

Why this matters

Say you're running more than one TLS-speaking service on the same IP — different protocols, different certificates, maybe different physical processes entirely. You only have one port 443. Normally you'd pick one service to own that port and proxy everything else through it at the application layer. That requires understanding the inner protocol.

ssl_preread lets nginx peek at just enough of the handshake to read the SNI field, before any decryption happens, and route the raw bytes accordingly. The backend sees the original, unmodified TLS handshake and terminates it itself.

The core config

stream {
    map $ssl_preread_server_name $backend {
        api.example.com      backend_a;
        admin.example.com    backend_b;
        default               backend_default;
    }

    upstream backend_a { server 127.0.0.1:9001; }
    upstream backend_b { server 127.0.0.1:9002; }
    upstream backend_default { server 127.0.0.1:9000; }

    server {
        listen 443;
        proxy_pass $backend;
        ssl_preread on;
    }
}

That's the whole mechanism. The map block matches the hostname the client asked for and picks an upstream; ssl_preread on tells nginx to inspect the handshake without proxying it as HTTP. No certificates are referenced anywhere in this block — nginx never sees the plaintext, so it doesn't need one.

Gotchas

Verifying it's working

Check what's actually bound to the port, not just what the config says:

ss -tlnp | grep :443

And test routing directly against a specific SNI name without touching DNS:

openssl s_client -connect YOUR_IP:443 -servername api.example.com

If the handshake completes and you can see the right backend's certificate in the response, routing is correct. If it hangs, double check the upstream is actually listening on the port you think it is — a backend bound to 0.0.0.0 instead of 127.0.0.1 is a common source of "it works but I don't know why" configs that break later for unrelated reasons.

← Back to all posts