10.5 TLS Passthrough with SNI Routing
The party trick that makes security teams smile: route encrypted traffic by hostname without holding a single private key. Then the adult conversation: when not to.
Advanced
What you will learn
Terminate vs. passthrough — the decision, not the technology.
ssl_preread: reading the ClientHello's SNI field without decrypting anything.The routing pattern:
map $ssl_preread_server_name→ variableproxy_pass— a single listener on:9243, verified with two certificates.What you give up when you don't terminate (and the hybrid that recovers part of it).
ssl_preread's other exports and the mode's sharp edges.
1. Terminate or pass through?
Module 6 terminated TLS: NGINX held the certificate, decrypted, and became an L7 citizen — rewrites, caches, WAF, all of it. Passing through keeps the bytes encrypted and keeps you blind: the decision table you'll actually use in production:
Factor | Terminate (Module 6) | Passthrough (this lesson) |
|---|---|---|
Private keys live on | the proxy (guard them well) | only the backends |
L7 features (paths, headers, caches) | all available | none — bytes are bytes |
Client certificates (mTLS) | NGINX verifies, can inject headers | backend's job, end to end |
TLS session cache / tickets | NGINX's, one place | per backend — resumption gets flaky behind balancing |
Protocol upgrades (h2/h3) | negotiated per hop | client↔backend negotiate directly — h3 stays h3 |
Latency | decrypt + re-encrypt (µs, one more hop's crypto) | ~L4 proxy cost only |
Cert rotation | one place (the proxy) | every backend |
Typical passthrough tenants: gRPC/mTLS estates where the backend must see the client cert, Kafka with SASL/SCRAM, database TLS, and anything where "the load balancer cannot see plaintext" is a compliance line someone drew on a whiteboard.
2.
ssl_preread — reading without decrypting
TLS's ClientHello — the very first message the client sends — is sent in the clear. Inside it, the SNI extension (Server Name Indication, the same field Module 6's certs were named for) carries the ASCII hostname the client wants to reach, because that's how a shared-IP server picks a certificate before any crypto happens.
ssl_preread on; tells the stream module:
peek at the ClientHello just far enough to extract SNI (and a few neighbors), then stop parsing and pipe the bytes — including the ClientHello itself — untouched to the upstream. NGINX never sees a key, never decrypts a byte, and can't: it simply read a field the client shouted in plaintext anyway.
The export vocabulary:
Variable | Content |
|---|---|
| the SNI hostname (empty if the client sent none) |
| client's ALPN list (
|
| the TLS version of the ClientHello — your TLS-1.2-only-backend gatekeeper |
3. The routing pattern
The
:9243 router, complete and commented as taught:
# route by hostname — no ssl_certificate anywhere in sight
map $ssl_preread_server_name $tls_backend {
api.example.test 127.0.0.1:9100; # b-tls-a (holds the api cert)
alt.example.test 127.0.0.1:9101; # b-tls-b (holds the alt cert)
default 127.0.0.1:9100;
}
server {
listen 9243;
ssl_preread on; # peek at SNI, then get out of the way
proxy_pass $tls_backend; # variable proxy_pass = per-session routing
}Two things make this work, and both are load-bearing:
proxy_passwith a variable — the one from Lesson 10.2's "or a variable" aside, now doing real work. A variable target means no upstream group (no retry chain — a failed connect is a dead session, Lesson 10.2's receipts apply) and noproxy_next_upstream; the map chose, the session goes.The map + default — the router's entire control plane. An SNI-less client (old TLS libraries, some health checks) yields an empty
$ssl_preread_server_name, which matches no case and falls todefault— always define one.
Proof (real capture — note NGINX holds no certs; each backend served its own, and verification through the tunnel succeeded):
$ openssl s_client -connect 127.0.0.1:9243 -servername api.example.test -CAfile certs/ca.crt
subject=C=OO, O=Example Org, CN=api.example.test
issuer=C=OO, O=Example Org, CN=Example Org Root CA
Verify return code: 0 (ok)
$ openssl s_client -connect 127.0.0.1:9243 -servername alt.example.test -CAfile ...
subject=C=OO, O=Example Org, CN=alt.example.test
Verify return code: 0 (ok)
$ openssl s_client -connect 127.0.0.1:9243 -servername unknown.name.test -CAfile ...
subject=C=OO, O=Example Org, CN=api.example.test # <- map defaultSame port, same NGINX, zero decryption — three hostnames, three destinations. The
unknown.name.test line is the map's
default proving itself: unmatched SNI still gets
a backend (never a black hole — unless your default says so).
4. What you gave up, and the hybrid
The honest costs of blindness:
No L7 anything: no paths, no header hygiene (Module 4), no WAF (Module 7), no caching (Module 8), no JSON logs of requests (Module 12 will log these as bare sessions).
Load visibility shrinks to connect/bytes/session-time — enough for capacity, not for debugging.
TLS resumption + balancing fight: session tickets are keyed per backend, and a resumed session pins to wherever the ticket came from.
hash $ssl_preread_server_name(all names to one peer) or shared ticket keys on backends are the escapes.Client-cert flows work only because the backend terminates; the proxy can't even see that mTLS happened.
The hybrid that production actually runs — SNI routing first, termination where policy allows:
map $ssl_preread_server_name $target {
payments.example.com 127.0.0.1:9443; # passthrough: PCI zone, own certs
default 127.0.0.1:9444; # re-encrypt to the L7 tier
}Port
9444 is a Module 6-style terminating stream server (
listen 9444 ssl; ssl_certificate …) that re-encrypts into the fleet — one NGINX, both postures, chosen per hostname.
5. Sharp edges
Variable
proxy_passmeans no group — retries andmax_failsquarantine (Lesson 10.2) are gone. If a backend dies, sessions fail until the map changes. (Staticupstream+proxy_passcan't be mixed per-name withoutstream's upstream-on-variable limits — Module 13's dynamic-resolver pattern is the scalable answer.)SNI is optional — health checkers and ancient clients send none; your
defaultanswers them. Route accordingly.SNI is unauthenticated — any client can claim any name; the certificates do the authenticating at the backend. Don't use SNI alone as access control.
ALPN routing needs the whole list —
$ssl_preread_alpn_protocolsis a comma-joined blob ("h2,http/1.1"); map with regexes (~^h2…), not equality.One
ssl_prereadper listen — it's a server-level on/off, not per-upstream. Mixed estates belong in separateserver{}blocks (the hybrid above).
Try it yourself
openssl s_client -connect 127.0.0.1:9243 -servername <name>— api → api cert, alt → alt cert, unknown → default: all three routing cases, one command each (§3's capture shows the expected output).The setup:
b-tls-a/b-tls-b— two example backends holding different certs from the same CA — that's what makes routing provable withVerify return code: 0.Experiment: remove the
defaultline and connect with-servername ""— observe the failure shape (an unroutable session), then restore it.
Q&A
Q: Can passthrough do HTTP/3? A: QUIC is UDP —
listen … udp +
ssl_preread doesn't apply the same way (the QUIC ClientHello is encrypted! QUIC's SNI rides inside the TLS-in-QUIC handshake, and NGINX's OSS preread doesn't decode it). h3 passthrough needs a QUIC-aware relay — worth knowing before you promise it.
Q: How is this different from
stream{}
TLS termination? A: Termination (
listen … ssl; ssl_certificate …) makes NGINX an endpoint that then speaks plaintext to backends. Passthrough never ends the TLS session — it
routes one. Different trust boundaries, different feature sets.
Q: What does the backend see as the client? A: NGINX — Lesson 10.3's problem, now wearing TLS. Backends that need the real IP must accept PROXY protocol inside nothing — you can't prepend plaintext to a TLS stream. The answers are transparent proxying (kernel) or in-protocol metadata (SNI itself, client certs, or the app's own headers post-termination).
Takeaways
ssl_preread on;peeks at the plaintext ClientHello for SNI (and ALPN/protocol), then pipes the TLS stream untouched — routing without keys, without decryption, without liability.The pattern is
map $ssl_preread_server_name → variable proxy_pass; always define adefault, because SNI-less clients exist and empty-string routing is real.You trade all of L7 for keylessness — the hybrid (passthrough-for-some, terminate-and-re-encrypt-for-the-rest) is the production norm.
Variable
proxy_passhas no retry/quarantine semantics — failure handling moves to your map's freshness, which is exactly the problem Module 13 returns to solve with dynamic resolution.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.