6.1 TLS Termination: Certificates and Handshakes
The edge's first job: turn encrypted client bytes into clean HTTP for your network — and do it without becoming the bottleneck or the weak link.
Intermediate
What you will learn
What "termination" means architecturally — and its alternatives (passthrough, re-encryption).
The certificate directive family: files, chains, keys, and the one-filename mistake that breaks browsers.
SNI and multi-cert servers: one NGINX, many identities.
The handshake, annotated: what your access log knows about it.
The redirect discipline: HTTP→HTTPS done right, with the ACME carve-out.
1. Termination — the architectural choice
passthrough: client ══TLS══════════════▶ backend (Module 10)
termination: client ══TLS══▶ NGINX ──HTTP──▶ backends (this lesson)
re-encryption: client ══TLS══▶ NGINX ══TLS══▶ backends (mTLS inside)Terminating at NGINX buys the entire series: routing on encrypted traffic (Locations! Maps! headers!), caching (Module 8 — bytes must be readable to cache them), logging and rate limiting on content (Module 7/12), and one central place for certificate operations. The costs are honest: NGINX holds the private keys (harden that host), and back-to-back hops are plaintext unless you re-encrypt (the mTLS of Lesson 6.3, or
grpc_pass grpcs:// from Lesson 4.5, cover the sensitive paths).
When not to terminate: when backends must cryptographically verify clients end-to-end (let the L4 passthrough of Module 10 route by SNI instead), or when compliance forbids any intermediate plaintext (banks, health — rare, but real).
2. The certificate directive family
The modern server, verbatim:
server {
listen 8643 ssl;
http2 on;
server_name app.example.test;
ssl_certificate ../certs/app.example.test.pem; # cert + chain
ssl_certificate_key ../certs/app.example.test.key; # the private key
…
}The details that separate working configs from 3 a.m. pages:
ssl_certificatemust contain the FULL CHAIN: leaf first, then intermediates. A single-leaf file works in curl (which has your CA trusted) and fails in browsers and mobile clients (which need the intermediate sent). The example's.pemfiles are full chains —openssl x509shows the leaf;openssl crl2pkcs7 -nocrl -certfile … | openssl pkcs7 -print_certsshows everything.Paths resolve relative to the instance prefix (a lesson often learned live:
certs/…failed,../certs/…reaches the prefix-level symlink). Absolute paths are common in production — and both compose with secret managers that materialize files.Key permissions: the key file should be
0600, owned by root(master reads it at load; workers never need it — privilege separation from Lesson 1.4 doing security work).ssl_trusted_certificateis the third family member: the CA bundle for verifying others (client certs, OCSP responses) — not for serving your own chain. Keeping "what I present" vs "what I trust" straight prevents a whole class of config confusion.
Multiple certs, one server (RSA + ECDSA dual stacks, or a legacy fallback) is just repetition — NGINX picks per client capability:
ssl_certificate certs/site.ecdsa.pem; ssl_certificate_key certs/site.ecdsa.key;
ssl_certificate certs/site.rsa.pem; ssl_certificate_key certs/site.rsa.key;3. SNI: many identities per listener
The
Host-based virtual hosting you mastered in Lesson 2.2 has an encrypted twin:
SNI (Server Name Indication) — the client sends the intended hostname
inside the handshake's ClientHello, and NGINX picks the
server{} (and its certificate) before decryption. Same selection rules as Lesson 2.2, TLS edition:
server {
listen 8643 ssl;
server_name app.example.test;
ssl_certificate ../certs/app.example.test.pem;
ssl_certificate_key ../certs/app.example.test.key;
}
server {
listen 8643 ssl;
server_name api.example.test;
ssl_certificate ../certs/api.example.test.pem;
ssl_certificate_key ../certs/api.example.test.key;
}SNI works end-to-end — same port, different identities:
openssl s_client -servername … shows the right certificate each time. Two SNI edge cases to bank:
no SNI (ancient clients, some health checkers) falls to the default server (mark one!); and
unknown SNI does too — which
ssl_reject_handshake on; (in the default server) turns into an immediate, certificate-leaking-nothing refusal.
4. The handshake, annotated — and logged
What your TLS access log sees (the log format is built for exactly this):
127.0.0.1 [22/Sep/2026:21:13:41 +0000] "GET /satisfy-all/ HTTP/2.0" 200
proto=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384 reused=.One line, three forensics: protocol (
TLSv1.3), cipher (
TLS_AES_256_GCM_SHA384), and session reuse (
. = fresh handshake — Lesson 6.4 makes that column say
r). After any TLS policy change,
these are the fields that tell you what clients actually negotiated — audience truth, not config intent. (Full
$ssl_* variable tour:
$ssl_server_name = SNI,
$ssl_client_s_dn = the peer's subject when mTLS is on — Lesson 6.3 — and
$ssl_curves/
$ssl_early_data for the exotic.)
5. The redirect discipline
The port-8600 server is the canonical redirector:
server {
listen 8600;
server_name app.example.test;
# ACME carve-out: Let's Encrypt renewals MUST answer over plain HTTP
location /.well-known/acme-challenge/ {
root www/acme;
}
location / {
return 301 https://$host:8643$request_uri; # 443 in production
}
}Verified live:
$ curl -sI -H 'Host: app.example.test' http://127.0.0.1:8600/ | grep -i location
Location: https://app.example.test:8643/
$ curl -s -H 'Host: app.example.test' \
http://127.0.0.1:8600/.well-known/acme-challenge/demo-token
demo-token-content-for-acmeThree production notes:
Why 301, and when 302: permanent for permanent moves (caches learn it; HSTS does the rest — Lesson 6.2). Temporary while testing rollout. Never redirect-loop: the carve-out must precede the catch-all (it's a longer prefix — Lesson 2.3 does the work invisibly).
$hostnot$server_name: the request's name survives fronted deployments (Lesson 3.5's redirect-survival kit applies fully).Certbot automation: the official plugin writes its tokens under that path and can even reload NGINX for you (
--deploy-hook "nginx -s reload"). The carve-out + static token above demonstrates the exact request shape Let's Encrypt makes.
Try it yourself
Read a real handshake, field by field:
bashecho | openssl s_client -connect 127.0.0.1:8643 -servername app.example.test \ | grep -E "New,|subject=|Cipher is|Protocol"Map each output line to §4's log format.
Exercise SNI. Request with
-servername api.example.testagainst :8643 — observe the default server answer with the app certificate (no matching SNI → default; the cert won't match the name, which is itself the lesson).Break the chain on purpose. Make a leaf-only cert file (
openssl x509 -in …output alone) and pointssl_certificateat it, reload, and check withopenssl s_client -showcertshow many certificates arrive. Restore.The redirect, end to end: follow it with
curl -Lfrom :8600 to :8643 with the CA bundle, and watch HSTS arrive on the second hop (next lesson's header).
Q&A
Q1. Why must
ssl_certificate contain the full chain when curl works fine with just the leaf?
Answer
curl (with your CA in its trust store) can build the chain locally; browsers and most clients verify only what the server sends plus their root store — without the intermediate in the handshake, they fail. "Works in curl, breaks in Safari" is this exact file.
Q2. A client connects with no SNI to a listener hosting three certs. Which serves it?
Answer
The listener's default server (marked
default_server, or first in config — Lesson 2.2's trap, TLS edition). Guard with
ssl_reject_handshake on; there if unknowns shouldn't get a certificate.
Q3. Terminate or passthrough: (a) a public API behind NGINX with WAF rules; (b) an internal gRPC service where backends must authenticate calling services cryptographically.
Answer
(a) Terminate — WAF rules need plaintext; central certs; re-encrypt inward only if the network is untrusted. (b) Passthrough (Module 10's SNI routing) or mTLS-to-NGINX + mTLS-from-NGINX (re-encryption) — the backend's verification requirement rules out plain termination.
Q4. Why does the ACME location need to exist before the redirect catch-all, given both are prefix matches?
Answer
Location competition is by prefix length (Lesson 2.3):
/.well-known/acme-challenge/ is longer than
/, so it wins those URIs regardless of order — but
having it at all is what matters; without it, renewal requests would 301 to HTTPS and the CA can't follow that.
Takeaways
Termination centralizes certs, routing, caching, and inspection at the edge; passthrough and re-encryption are the two escape hatches with real use cases.
ssl_certificate= full chain (leaf + intermediates); keys are0600; trusted-vs-presented bundles are different directives.SNI drives server selection before decryption — same default-server rules as HTTP vhosts, with
ssl_reject_handshakeas the unknown-SNI guard.$ssl_protocol/$ssl_cipher/$ssl_session_reusedturn the access log into a TLS observatory — measure negotiations, don't assume them.The redirector is a discipline: carve out ACME, use
$host, choose 301 deliberately, and never create loops.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.