Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

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.

Levels: Intermediate ~40 min read

Intermediate

What you will learn

  1. What "termination" means architecturally — and its alternatives (passthrough, re-encryption).

  2. The certificate directive family: files, chains, keys, and the one-filename mistake that breaks browsers.

  3. SNI and multi-cert servers: one NGINX, many identities.

  4. The handshake, annotated: what your access log knows about it.

  5. The redirect discipline: HTTP→HTTPS done right, with the ACME carve-out.


1. Termination — the architectural choice

text
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:

nginx
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_certificate must 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 .pem files are full chains — openssl x509 shows the leaf; openssl crl2pkcs7 -nocrl -certfile … | openssl pkcs7 -print_certs shows 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_certificate is 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:

nginx
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:

nginx
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):

text
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:

nginx
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:

bash
$ 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-acme

Three 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).

  • $host not $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

  1. Read a real handshake, field by field:

    bash
       echo | 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.

  2. Exercise SNI. Request with -servername api.example.test against :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).

  3. Break the chain on purpose. Make a leaf-only cert file ( openssl x509 -in … output alone) and point ssl_certificate at it, reload, and check with openssl s_client -showcerts how many certificates arrive. Restore.

  4. The redirect, end to end: follow it with curl -L from :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 are 0600; trusted-vs-presented bundles are different directives.

  • SNI drives server selection before decryption — same default-server rules as HTTP vhosts, with ssl_reject_handshake as the unknown-SNI guard.

  • $ssl_protocol/ $ssl_cipher/ $ssl_session_reused turn 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.

Was this page helpful?

Mark this learning resource complete to track your learning progress.