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
- The stream module isn't always compiled in. On Debian/Ubuntu, the base
nginxpackage often omits it; you neednginx-fullorlibnginx-mod-stream, and after installing you still have to addstream {}tonginx.confyourself — it's not loaded automatically. A package reinstall can also silently wipe a hand-editednginx.confback to the distro default, which is a fun way to lose a stream block you forgot you'd written by hand. - Clients that don't send SNI fall through to
default, so always define one, even if it just points at an error page. - This is TCP-layer routing, not HTTP-layer. If you need path-based or header-based routing, this isn't the tool — go back to a normal reverse proxy once TLS is terminated somewhere.
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.