Module 08 — Deploying TLS: NGINX, Apache & the Chain-Order Trap

Updated 24 August 2026

Module 08 · Deploying TLS: NGINX, Apache & the Chain-Order Trap

You can issue a certificate, build a chain, and explain exactly why a client rejected one. This module puts it on a real web server — the files, the config lines, the permissions, and the four mistakes that cause almost every TLS deployment incident.

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01–07. You need fullchain from Module 02, chain order from Module 05, the handshake from Module 06, and the failure signatures from Module 07.


The picture to hold in your head for this whole module — opening a shop.

To trade legally you need three things on the premises:

  1. The licence on the wall — your certificate. Customers can see it.
  2. The paperwork showing who licensed you — the chain. Also public, and you must hand it over, because the customer cannot look it up.
  3. The key to the front door — your private key. Nobody sees this. Ever.

Almost every deployment failure in this module is one of two things: you forgot to hand over the paperwork, or you left the key where someone could take it.

About this module. The configuration here is verified against the official nginx and Apache mod_ssl documentation, directive by directive, including defaults.

The file-level exercises — chain order, permissions, verification — run offline with openssl and the certificates from Module 05. You do not need to install nginx to do them, and on a work laptop you should not.

If you want to try the configs for real, use a container:

bash
docker run --rm -it -p 8443:443 -v ~/tls-lab/m05:/certs nginx:alpine sh

Nothing touches your host, and --rm deletes it when you exit.

Part A · Getting the files right

A1 · Three files, and only three

FileSecret?What it is, and where it came from
fullchain.pemNo — publicYour certificate, then the intermediates. Handed to every client (Module 05, D1)
privkey.pemYESThe private key. 0600, never leaves the host, never in git (Module 02, A3)
chain.pemNo — publicThe intermediates alone. Only needed for OCSP stapling (Part D2)
The root does not appear in that table, and that is deliberate.

You never serve the root. The client either has it or it does not, and a root arriving from a server is ignored either way (Module 05, A1.1). Including it just makes every handshake bigger.

So: leaf + intermediates in one file, key in another, root nowhere.

🧪 Exercise A1.1 — Assemble a deployable set from Module 05's lab
bash
cd ~/tls-lab/m05

cat app.crt int/int.crt > fullchain.pem
cp app.key privkey.pem
cp int/int.crt chain.pem
chmod 600 privkey.pem

ls -l fullchain.pem privkey.pem chain.pem

echo "=== what is in each? ==="
for f in fullchain.pem chain.pem; do
  printf '%-16s %s certificate(s)\n' "$f" "$(grep -c 'BEGIN CERTIFICATE' $f)"
done
head -1 privkey.pem
Expected result — click to reveal
plain text
-rw------- 1 zaeem zaeem 1956 Aug 20 15:40 chain.pem
-rw------- 1 zaeem zaeem 3384 Aug 20 15:40 fullchain.pem
-rw------- 1 zaeem zaeem  241 Aug 20 15:40 privkey.pem

=== what is in each? ===
fullchain.pem    2 certificate(s)
chain.pem        1 certificate(s)
-----BEGIN PRIVATE KEY-----

What to read out of this.

  • fullchain.pem has 2, chain.pem has 1. fullchain = leaf + intermediates. chain = intermediates only. The names are Let's Encrypt's convention and have become the general standard — and mixing them up is a common source of confusion, because both files are valid PEM and neither tool will complain.
  • privkey.pem is 0600 and everything else is too, because of umask 077. In a real deployment the two public files should be 0644 so the web server's worker processes can read them without special privilege — but the key must stay 0600 and owned by root.
  • head -1 on the key shows BEGIN PRIVATE KEY — PKCS#8, unencrypted. That is what a web server needs: an encrypted key means the service cannot start unattended (Module 02, A3.2).

💡 The three-second pre-deploy check, which catches most file mistakes before they become incidents:

bash
grep -c 'BEGIN CERTIFICATE' fullchain.pem     # expect 2 or 3, NEVER 1
head -1 privkey.pem                            # expect BEGIN PRIVATE KEY, not ENCRYPTED
diff <(openssl x509 -in fullchain.pem -noout -pubkey) <(openssl pkey -in privkey.pem -pubout)

🎯 Interview questions — The files

Q. What files does a web server need to serve HTTPS, and which are secret?

Two files at minimum, three if you use OCSP stapling:

  • fullchain.pem — the leaf certificate followed by the intermediates. Public. It is sent to every client on every full handshake.
  • privkey.pem — the private key. Secret. 0600, owned by root, never in version control, never leaves the host it was generated on.
  • chain.pem — intermediates only. Public. Needed by nginx's ssl_trusted_certificate for OCSP stapling verification.

The root is never served. A client that trusts it already has it; a client that does not will not trust it because a server offered one.

The detail worth adding: the certificate being public is not a nuance to hedge — it is genuinely published in Certificate Transparency logs and anyone can fetch it from your server. Treating it as sensitive leads to strange workflows. The key is the only irreplaceable, secret thing in the set.


A2 · The chain-order trap

The analogy — a stack of papers, stapled in order.

You hand the customer a small stack: your licence on top, then the document that licensed you underneath it.

The web server does not read your stack. It just takes whatever is on top and treats that as your licence, and hands the rest over as supporting paperwork.

So if you staple them the other way round, the server confidently presents the licensing office's document as if it were yours. It is a perfectly genuine document. It is not yours, and it has the wrong name on it.

This is why order matters, and it is more subtle than "order matters".

In Module 05 (D1.2) you proved that openssl verify accepts a chain in any order — it searches. So people conclude order is unimportant.

But nginx reads the file positionally: first certificate = the leaf, everything after = the chain. Reverse the file and nginx serves the intermediate as your server certificate. The certificate is valid, the chain is valid, and every client fails with hostname mismatch — because the intermediate's name is not your website.

The file is not corrupt. The deployment is wrong. And the error points at the hostname, which sends people looking at DNS and SANs instead of at the file.

🧪 Exercise A2.1 — Reverse the file, and see what nginx would serve
bash
cd ~/tls-lab/m05

cat app.crt int/int.crt > correct.pem
cat int/int.crt app.crt > reversed.pem

echo "=== what would the server treat as ITS certificate? ==="
printf 'correct.pem  -> '; openssl x509 -in correct.pem  -noout -subject
printf 'reversed.pem -> '; openssl x509 -in reversed.pem -noout -subject

echo
echo "=== but BOTH still verify as a bag of certificates ==="
openssl verify -CAfile root/root.crt -untrusted correct.pem  app.crt
openssl verify -CAfile root/root.crt -untrusted reversed.pem app.crt
Expected result — click to reveal
plain text
=== what would the server treat as ITS certificate? ===
correct.pem  -> subject=C = MY, O = Zaeem Labs, CN = app.internal.test
reversed.pem -> subject=C = MY, O = Zaeem Labs, CN = Zaeem Labs Issuing CA 1

=== but BOTH still verify as a bag of certificates ===
app.crt: OK
app.crt: OK

What to read out of this — the two blocks say opposite things, and both are true.

  • openssl x509 -in reversed.pem -noout -subject prints the CA's name. openssl x509 reads only the first certificate in a file — exactly as nginx does. That one command tells you what your server will actually present.
  • Both files verify fine. As a set of certificates, order is irrelevant. This is why "it verifies, so the file must be right" is a false conclusion.
  • The reversed deployment fails with hostname mismatch (code 62), not with a chain error — because the chain is fine, and the certificate being served is simply for the wrong name.

🔑 So the pre-deploy check is one line, and it is the right one:

bash
openssl x509 -in fullchain.pem -noout -subject

If that does not print your website's name, the file is in the wrong order. Checking grep -c BEGIN tells you the count; this tells you the order. You want both.

💡 Where reversed files come from in real life: a cat written the wrong way round in a renewal script, a CA portal that offers the download as "certificate bundle" with the root first, or someone assembling the file by hand from a support email. It is almost never a tool's fault.

🎯 Interview questions — Chain order

Q. Does the order of certificates in fullchain.pem matter?

Yes — but not for the reason people usually give.

For path building, order is irrelevant. The client treats the certificates as a set and searches, so openssl verify accepts any order.

For the server, order is everything. nginx and Apache read the file positionally: the first certificate is the one presented as the server certificate, and everything after is the chain. Reverse the file and nginx serves the intermediate as your certificate — producing a hostname mismatch on every client, because the intermediate's name is not your site.

RFC 8446 also specifies leaf-first on the wire; modern clients tolerate a mis-ordered chain, but tolerating is not the same as correct.

The check that catches it: openssl x509 -in fullchain.pem -noout -subject. That reads only the first certificate — exactly what nginx does. If it does not print your site's name, the order is wrong.

Why this is worth knowing precisely: the resulting error blames the hostname, so people go looking at DNS and SANs. Knowing that a reversed bundle produces a hostname error saves a long detour.


A3 · The private key on the server

The analogy — where you keep the shop key.

The licence goes on the wall where everyone can read it. The key to the front door goes in your pocket.

The mistake people make is leaving the key on the counter — technically inside the shop, but anyone who gets behind the counter has it. On a web server that is a key readable by the worker processes, which is one web-application vulnerability away from being readable by an attacker.

The key should be readable by root at startup, and by nobody afterwards.

Why this works, and it surprises people. nginx and Apache both start as root, read the key, then drop privileges to an unprivileged user for the worker processes.

So the key can be 0600 root:root and everything still works — the workers never need to read it, because the master process already loaded it into memory before dropping privileges.

If someone tells you the key must be readable by www-data, they are wrong, and they have widened the blast radius of every web application bug on that host.

🧪 Exercise A3.1 — Check permissions the way an auditor would
bash
cd ~/tls-lab/m05

# what a correct deployment looks like
chmod 600 privkey.pem
chmod 644 fullchain.pem chain.pem
ls -l privkey.pem fullchain.pem

echo "=== find any key on this machine that is too open ==="
find ~/tls-lab -name '*.key' -o -name 'privkey*.pem' 2>/dev/null | while read -r f; do
  perms=$(stat -c '%a' "$f" 2>/dev/null || stat -f '%Lp' "$f")
  case "$perms" in
    600|400) printf '  ✅ %-40s %s\n' "$f" "$perms" ;;
    *)       printf '  ❌ %-40s %s  <- TOO OPEN\n' "$f" "$perms" ;;
  esac
done
Expected result — click to reveal
plain text
-rw-r--r-- 1 zaeem zaeem 3384 Aug 20 15:40 fullchain.pem
-rw------- 1 zaeem zaeem  241 Aug 20 15:40 privkey.pem

=== find any key on this machine that is too open ===
  ✅ /home/zaeem/tls-lab/m05/app.key                    600
  ✅ /home/zaeem/tls-lab/m05/privkey.pem                600
  ✅ /home/zaeem/tls-lab/m05/int/private/int.key        600
  ✅ /home/zaeem/tls-lab/m05/root/private/root.key      600

What to read out of this.

  • -rw-r--r-- for the certificate, -rw------- for the key. The certificate is public; making it 0600 achieves nothing and occasionally breaks tools that read it as a non-root user.
  • Everything is 600 because umask 077 has been set at the top of every exercise since Module 01. That habit is doing real work — without it, openssl writes keys with whatever the default umask gives, which on many systems is 0644.
  • The permissions on the enclosing directory matter too. A 0600 key inside a 0755 directory is fine; inside a world-writable directory it is not, because someone could replace it. Production key directories are usually 0700 root:root.

⚠️ Two exposures that permissions do not fix, and both are more common than bad permissions:

  1. The key in git. Deleting the file does not remove it from history. If a key has ever been committed, it is burned — rotate it (Module 02, A2 interview question).
  2. The key in a backup, a container image layer, or a config-management repo. A key baked into a Docker image is in every registry copy of that image, permanently.

Now imagine this at 500 hosts. The structural fix is that keys are generated on the host and never travel (Module 04, D3). ACME clients do this by default. If your process involves a human moving a key file between machines, that is the thing to change — not the permissions.

🎯 Interview questions — Key handling on servers

Q. What permissions should a TLS private key have on a web server, and why?

0600, owned by root, in a directory that is 0700 root:root. The certificate can be 0644 because it is public.

The key does not need to be readable by www-data or nginx. Both nginx and Apache start as root, read the key into memory, then drop privileges for the worker processes. Making the key readable by the worker user means any web application vulnerability that can read files can read your private key — for no benefit.

The exposures permissions do not cover, and which matter more:

  • Git history — permanent. A key ever committed must be rotated, not deleted.
  • Container image layers — a key baked into an image exists in every copy of that image in every registry.
  • Backups and config-management repos — usually less protected than the server itself.

The structural answer: generate the key on the host that will use it and never move it. That is what ACME clients do by default, and it removes the whole class of problem rather than mitigating it.


Part B · NGINX

B1 · A minimal working config, line by line

javascript
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;                                    # nginx 1.25.1+ ; older: listen 443 ssl http2;
    server_name shop.example.com;

    # --- the two files that matter ---
    ssl_certificate     /etc/ssl/shop/fullchain.pem;   # leaf FIRST, then intermediates
    ssl_certificate_key /etc/ssl/shop/privkey.pem;     # 0600 root:root

    # --- protocol and ciphers ---
    ssl_protocols       TLSv1.2 TLSv1.3;               # this is already the default
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;                     # correct for TLS 1.3

    # --- resumption ---
    ssl_session_cache   shared:SSL:10m;                # default is 'none' - always set this
    ssl_session_timeout 1d;
    ssl_session_tickets off;                           # unless you rotate ticket keys

    # --- OCSP stapling (Part D2) ---
    ssl_stapling        on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/ssl/shop/chain.pem;

    # --- HSTS (Part D1) ---
    add_header Strict-Transport-Security "max-age=31536000" always;

    root /var/www/shop;
}
The analogy — the instruction sheet for whoever opens the shop.

Each line answers one question a new member of staff would ask: which door do I open (listen), whose shop is this (server_name), where is the licence (ssl_certificate), where is the key (ssl_certificate_key), which languages do we serve customers in (ssl_protocols, ssl_ciphers), do we let regulars skip the ID check (ssl_session_*).

Nothing here is decorative.

Directivenginx defaultWhy the config above sets it anyway
ssl_protocolsTLSv1.2 TLSv1.3Already correct. Set it explicitly so an inherited config cannot loosen it
ssl_ciphersHIGH:!aNULL:!MD5The default is broad and includes CBC suites. Take a maintained list instead
ssl_prefer_server_ciphersoffoff is correct. It has no effect in TLS 1.3, and in 1.2 letting the client choose is now preferred
ssl_session_cachenone⚠️ The default means no resumption at all. Always set this
ssl_session_timeout5mVery short. 1d is normal for a busy site
ssl_session_ticketson⚠️ On by default with keys that are never rotated unless you configure it (Part D3)
ssl_staplingoffOff by default. Worth enabling (Part D2)
ssl_early_dataoffLeave it off unless you have handled 0-RTT replay (Module 06, D3)
The two defaults that catch people, and they pull in opposite directions.

ssl_session_cache none means nginx does no session-ID resumption out of the box. Every connection is a full handshake. This is a pure performance loss and costs one line to fix.

ssl_session_tickets on means TLS session tickets are enabled by default — using a key nginx generates at startup and never rotates for the lifetime of the process. A long-running nginx is therefore protecting months of resumed sessions with one static key, which quietly undermines forward secrecy (Module 06, D3).

So the defaults give you the resumption mechanism with the security caveat, and not the one without it. Set both explicitly.

When a deployment fails, the symptom points at the cause more precisely than people expect:

Diagram source
flowchart TD
    F["💥 TLS deployment failing"] --> Q1{"What is the error?"}
    Q1 -->|"hostname mismatch<br>code 62"| A1{"Does the bundle's<br>FIRST cert have<br>your site's name?"}
    A1 -->|"no - it is the CA"| R1["🔄 REVERSED BUNDLE<br>cat leaf.crt int.crt > fullchain.pem"]
    A1 -->|"yes"| R2["🏷️ SAN or SNI problem<br>compare with/without -servername"]
    Q1 -->|"unable to get<br>local issuer"| A2{"How many certs<br>does it send?"}
    A2 -->|"1"| R3["⛓️ INCOMPLETE CHAIN<br>serve fullchain.pem"]
    A2 -->|"2 or more"| R4["🏢 UNTRUSTED ROOT<br>fix the CLIENT trust store"]
    Q1 -->|"expired, but we<br>renewed it"| A3{"Disk fingerprint<br>== wire fingerprint?"}
    A3 -->|"different"| R5["🔁 NEVER RELOADED<br>reload + add a deploy-hook"]
    A3 -->|"same"| R6["📅 Genuinely expired<br>check EVERY depth"]
    Q1 -->|"service will<br>not start"| A4{"head -1 privkey.pem"}
    A4 -->|"ENCRYPTED"| R7["🔐 Passphrase on the key<br>strip it, or use a secret manager"]
    A4 -->|"PRIVATE KEY"| R8["🔑 key values mismatch<br>diff the public keys"]
    style R1 fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
    style R3 fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
    style R5 fill:#ffcccc,stroke:#cc0000,stroke-width:2px
    style R7 fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
Every branch in that tree is decided by one command, and none of them changes anything:

openssl x509 -in fullchain.pem -noout -subject · grep -c 'BEGIN CERTIFICATE' · the disk-versus-wire fingerprint comparison · head -1 privkey.pem.

That is the whole of TLS deployment troubleshooting, and it is why the five pre-deploy checks below are worth building into a gate rather than remembering.

🧪 Exercise B1.1 — Validate a config without a running server
bash
cd ~/tls-lab/m05

echo "=== 1. does the file order put the leaf first? ==="
openssl x509 -in fullchain.pem -noout -subject

echo "=== 2. is the chain complete? ==="
grep -c 'BEGIN CERTIFICATE' fullchain.pem

echo "=== 3. does the key match the certificate? ==="
diff <(openssl x509 -in fullchain.pem -noout -pubkey) \
     <(openssl pkey -in privkey.pem -pubout) && echo "MATCH"

echo "=== 4. is the key unencrypted, so the service can start unattended? ==="
head -1 privkey.pem

echo "=== 5. permissions ==="
stat -c '%a %n' privkey.pem fullchain.pem 2>/dev/null || stat -f '%Lp %N' privkey.pem fullchain.pem
Expected result — click to reveal
plain text
=== 1. does the file order put the leaf first? ===
subject=C = MY, O = Zaeem Labs, CN = app.internal.test

=== 2. is the chain complete? ===
2

=== 3. does the key match the certificate? ===
MATCH

=== 4. is the key unencrypted? ===
-----BEGIN PRIVATE KEY-----

=== 5. permissions ===
600 privkey.pem
644 fullchain.pem

What to read out of this — these five checks catch nearly every deployment failure.

CheckWhat it preventsSymptom if you skip it
1. Leaf firstReversed bundlehostname mismatch on every client
2. Count ≥ 2Incomplete chainWorks in browsers, fails in curl/Java/Go
3. Key matchesMismatched pairkey values mismatchservice will not start
4. Not encryptedPassphrase on the keyService hangs at boot waiting for input
5. 0600Key exposureNo symptom at all, which is the problem

🔑 Notice that four of the five have completely different symptoms, and two of them do not show up until a restart that may be days later. That is why these belong in a pre-deploy gate rather than in your head. Five commands, no server needed, and they run in under a second.

💡 When you do have nginx, add the two commands it provides:

bash
nginx -t                  # parse and validate the config - ALWAYS before a reload
nginx -T | grep ssl_      # dump the FULLY RESOLVED config, includes and all

nginx -T is the underused one. It shows what nginx actually ended up with after every include, which is how you find the stray ssl_protocols TLSv1; in a file nobody remembered was included.

🎯 Interview questions — nginx configuration

Q. Walk me through configuring HTTPS on nginx.

The two directives that matter are ssl_certificate pointing at fullchain.pem — leaf first, then intermediates — and ssl_certificate_key pointing at the key, which should be 0600 root:root. nginx starts as root, reads the key, and drops privileges, so the workers never need access to it.

Then listen 443 ssl and http2 on (or listen 443 ssl http2 before nginx 1.25.1), and server_name for SNI-based virtual hosting.

For the rest I would take a maintained configuration from TLSRef rather than hand-writing cipher strings, and set explicitly: ssl_session_cache shared:SSL:10m — because the default is none, meaning no resumption at all — and ssl_session_tickets off unless ticket-key rotation is handled, because the default is on with a key that never rotates.

The check I would always run before reloading: nginx -t to validate, and nginx -T to dump the fully resolved config, which is how you find directives inherited from an include you had forgotten about.

And the five file-level checks — leaf first, chain count, key match, key unencrypted, permissions — because four of the five have completely different symptoms and two only appear at the next restart.


B2 · Redirecting HTTP to HTTPS

The analogy — the sign on the old door.

You have moved from the front door to the side entrance. You put a sign on the front door.

A 302 sign says "we're round the side today" — the customer walks round, but tries the front door again tomorrow.

A 301 sign says "we have moved permanently" — the customer writes it down and goes straight to the side entrance from now on.

You want 301, because you want browsers to stop trying the insecure door at all.

javascript
# The HTTP server exists only to send people away
server {
    listen 80;
    listen [::]:80;
    server_name shop.example.com www.shop.example.com;

    return 301 https://$host$request_uri;
}
Three mistakes in four lines, all of them common.

rewrite instead of return. rewrite ^ https://$host$request_uri permanent; works, but return is faster and clearer — nginx's own documentation recommends it. rewrite runs the regex engine for something that needs no regex.

$server_name instead of $host. $server_name is the first name in the server_name list, so a request to www.shop.example.com would be redirected to shop.example.com — silently changing the hostname. $host preserves what the client actually asked for.

A hard-coded domain. return 301 https://shop.example.com$request_uri; breaks the moment the block serves more than one name, and it is copied between vhosts constantly.

One thing you must NOT redirect: the ACME challenge path.

If you use Let's Encrypt with the HTTP-01 challenge (Module 10), the CA fetches http://your-site/.well-known/acme-challenge/... over plain HTTP. Redirecting everything to HTTPS is usually fine — most ACME clients follow the redirect — but if the HTTPS certificate is broken or expired, the redirect leads somewhere the CA cannot validate, and renewal fails precisely when you most need it to work.

The safe pattern is to serve that one path directly:

javascript
server {
    listen 80;
    server_name shop.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;          # served over HTTP, not redirected
    }
    location / {
        return 301 https://$host$request_uri;
    }
}

This is a genuinely nasty failure mode: the certificate expires, HTTPS breaks, renewal depends on HTTPS, and you cannot renew your way out of it.

🧪 Exercise B2.1 — Check a redirect the way you should
bash
# Against any real site
curl -sS -o /dev/null -D - http://example.com/ 2>&1 | head -6

echo "=== follow it and see where you land ==="
curl -sSL -o /dev/null -w '%{url_effective}  final=%{http_code}  redirects=%{num_redirects}\n' \
  http://example.com/

echo "=== does it preserve the path and the hostname? ==="
curl -sS -o /dev/null -D - "http://example.com/some/path?a=1" 2>&1 | grep -i '^location:'
Expected result — click to reveal
plain text
HTTP/1.1 301 Moved Permanently
Location: https://example.com/
Content-Length: 0

=== follow it and see where you land ===
https://example.com/  final=200  redirects=1

=== does it preserve the path and the hostname? ===
location: https://example.com/some/path?a=1

What to read out of this.

  • 301, not 302. A permanent redirect lets the browser remember it and skip the insecure request next time. 302 means it tries HTTP again on every visit, leaving a plaintext request on the wire every time.
  • redirects=1. One hop. A common misconfiguration produces http://x → https://x → https://www.x, which is two hops and two round trips before anything useful happens. %{num_redirects} is the quick way to spot it.
  • The path and query string survived. $request_uri includes both. If you see Location: https://example.com/ for a request to /some/path, someone used $uri (path only, no query) or hard-coded a /, and every deep link into the site is being dropped on the floor.

🔑 The three things a good redirect does: status 301, one hop, and the full original path and query preserved. curl -sSL -w '%{url_effective} %{num_redirects}' checks all three in one command.

⚠️ And the thing a redirect cannot fix: the very first request still went out over plain HTTP, revealing the hostname and path to anyone on the path. That is what HSTS solves (Part D1) — it stops the insecure request being made at all after the first visit.


B3 · Reload vs restart — the "but we renewed it" bug

The analogy — a new price list, and nobody told the staff.

You print the new prices and put them in the back office. The staff on the till are still working from the copy they memorised this morning.

Nothing is wrong with the new list. Nothing is wrong with the staff. The information simply has not reached the people using it.

A web server reads its certificate once, at startup, and keeps it in memory. Writing a new file changes nothing until you tell the running process to re-read it.

This is the single most common certificate outage there is, and it is worth being precise about why it is so nasty:
  1. The renewal runs successfully. The new certificate is on disk. Every log says success.
  2. Nothing reloads. The running process still holds the old certificate.
  3. Monitoring that checks the file reports everything healthy.
  4. Weeks later the old certificate expires — or an unrelated deploy restarts the service and it suddenly starts working, which is even more confusing.

The renewal and the outage are separated by weeks, so nobody connects them.

ServerReload — no dropped connectionsWhat actually happens
nginxnginx -s reload · systemctl reload nginxMaster re-reads config and certificates, starts new workers, old workers finish their in-flight requests then exit
Apacheapachectl -k graceful · systemctl reload apache2Same idea — children finish current requests before being replaced
HAProxysystemctl reload haproxySeamless reload with socket hand-off on modern versions
Anysystemctl restart …⚠️ Drops connections. Works, but there is no reason to prefer it
🧪 Exercise B3.1 — Prove the file and the live endpoint can disagree

This is the whole bug in four commands. Use the lab server from Module 06.

bash
cd ~/tls-lab/m05
export no_proxy="*"

# start a server holding the CURRENT certificate
nohup openssl s_server -cert app.crt -cert_chain int/int.crt -key app.key \
      -accept 4433 -naccept 20 > /tmp/srv.log 2>&1 &
sleep 1

echo "=== fingerprint of the certificate the RUNNING SERVER is serving ==="
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test </dev/null 2>/dev/null \
  | openssl x509 -noout -fingerprint -sha256 | cut -c1-40

# now "renew" - write a brand new certificate over the file
cp app.crt app.crt.bak
openssl x509 -req -in app.csr -CA int/int.crt -CAkey int/private/int.key \
  -out app.crt -days 90 -extfile /dev/stdin <<'EOF' 2>/dev/null
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=serverAuth
subjectAltName=DNS:app.internal.test,DNS:www.app.internal.test
EOF

echo "=== fingerprint of the certificate now ON DISK ==="
openssl x509 -in app.crt -noout -fingerprint -sha256 | cut -c1-40

echo "=== and what the server is STILL serving ==="
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test </dev/null 2>/dev/null \
  | openssl x509 -noout -fingerprint -sha256 | cut -c1-40

pgrep -f 'accept 4433' | xargs -r kill
mv app.crt.bak app.crt
Expected result — click to reveal
plain text
=== fingerprint of the certificate the RUNNING SERVER is serving ===
sha256 Fingerprint=3F:9A:2C:8E:5B:1D:47:F0

=== fingerprint of the certificate now ON DISK ===
sha256 Fingerprint=A1:7C:44:2B:E9:06:D3:58

=== and what the server is STILL serving ===
sha256 Fingerprint=3F:9A:2C:8E:5B:1D:47:F0

What to read out of this.

  • The disk changed and the wire did not. Two different fingerprints, same moment in time. The renewal "worked" by every measure a file-based check would use.
  • Nothing here is broken. The server is behaving correctly — it read the certificate at startup and has no reason to look again. openssl s_server has no reload; a real server does.
  • This is exactly what happens in production, except the gap between the two fingerprints is weeks rather than seconds, and the failure arrives when the old certificate expires.

🔑 The two rules that prevent it entirely:

1. Every renewal must trigger a reload. In certbot that is a --deploy-hook, which runs only when a certificate was actually renewed:

bash
certbot renew --deploy-hook "systemctl reload nginx"

Use --deploy-hook, not --post-hook — the latter runs on every check, reloading nginx twice a day for no reason.

2. Monitor the live endpoint, not the file. This is the same lesson as Module 05 (D3.1), and this exercise is the proof of why it matters. Compare them directly:

bash
disk=$(openssl x509 -in /etc/ssl/shop/fullchain.pem -noout -fingerprint -sha256)
wire=$(openssl s_client -connect shop.example.com:443 -servername shop.example.com </dev/null 2>/dev/null \
       | openssl x509 -noout -fingerprint -sha256)
[ "$disk" = "$wire" ] && echo "in sync" || echo "MISMATCH - the server needs a reload"

That check takes a second and catches the outage weeks before it happens.

🎯 Interview questions — Renewal and reload

Q. A certificate was renewed two weeks ago and the site has just gone down with an expiry error. What happened?

Almost certainly the renewal wrote a new certificate to disk and nothing reloaded the service. The running process read its certificate at startup and kept it in memory, so it carried on serving the old one until that expired.

It hides well: the renewal logs success, the file on disk is correct and current, and any monitoring that checks the file reports healthy. The renewal and the outage are separated by weeks, so nobody connects them.

The two fixes, and both are needed:

  1. A deploy hook on renewalcertbot renew --deploy-hook "systemctl reload nginx". Specifically --deploy-hook, which runs only when a certificate actually changed, rather than --post-hook, which runs on every check.
  2. Monitor the live endpoint, not the file. Comparing the fingerprint on disk with the fingerprint on the wire detects the drift immediately rather than at expiry.

And the detail worth adding: use reload, not restart. nginx's master process re-reads the certificate and starts new workers while old workers finish their in-flight requests, so no connections are dropped. A restart works but has no advantage.


Part C · Apache

C1 · The same deployment in mod_ssl

javascript
<VirtualHost *:443>
    ServerName shop.example.com

    SSLEngine on
    SSLCertificateFile      /etc/ssl/shop/fullchain.pem   # leaf + intermediates, since 2.4.8
    SSLCertificateKeyFile   /etc/ssl/shop/privkey.pem     # 0600 root:root

    SSLProtocol             -all +TLSv1.2 +TLSv1.3
    SSLCipherSuite          ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
    SSLHonorCipherOrder     off

    Header always set Strict-Transport-Security "max-age=31536000"

    DocumentRoot /var/www/shop
</VirtualHost>

# these two live at server level, NOT inside a VirtualHost
SSLUseStapling          On
SSLStaplingCache        "shmcb:logs/ssl_stapling(32768)"
SSLSessionCache         "shmcb:logs/ssl_scache(512000)"
nginxApache equivalent
ssl_certificateSSLCertificateFile
ssl_certificate_keySSLCertificateKeyFile
ssl_protocols TLSv1.2 TLSv1.3SSLProtocol -all +TLSv1.2 +TLSv1.3
ssl_ciphersSSLCipherSuite
ssl_prefer_server_ciphersSSLHonorCipherOrder
ssl_stapling onSSLUseStapling OnSSLStaplingCache (server level)
ssl_session_cacheSSLSessionCache (server level)
nginx -tapachectl configtest
nginx -s reloadapachectl -k graceful
SSLCertificateChainFile is obsolete, and you will still see it everywhere.

Apache's own documentation says it "became obsolete with version 2.4.8, when SSLCertificateFile was extended to also load intermediate CA certificates from the server certificate file."

Before 2.4.8 you needed two directives — one for the leaf, one for the chain. Since 2.4.8 you point SSLCertificateFile at fullchain.pem and that is all.

Why it matters: a huge amount of documentation and a lot of copied configs still use the old two-file form. It still works, but if someone hands you a config with SSLCertificateChainFile in it, that config was written for Apache 2.4.7 or earlier — which tells you something about how old the rest of it is too.

Apache 2.4.8 was released in 2014.

Two Apache-specific traps worth knowing:

SSLProtocol -all +TLSv1.2 +TLSv1.3, not SSLProtocol TLSv1.2 TLSv1.3. Apache's syntax is additive with + and -. Without the leading -all you may be adding to an inherited set rather than replacing it, and quietly leaving TLS 1.0 enabled.

Header always set, not Header set. Without always, the header is only added to successful (2xx) responses — so your HSTS header disappears on redirects and error pages, which are exactly the responses an attacker might induce. nginx has the same trap with its own always parameter on add_header.

🧪 Exercise C1.1 — Read an unfamiliar config and spot the problems

Below is a real-looking Apache TLS config. Find four things wrong with it before opening the answer.

javascript
<VirtualHost *:443>
    ServerName shop.example.com
    SSLEngine on
    SSLCertificateFile      /etc/ssl/shop/cert.pem
    SSLCertificateChainFile /etc/ssl/shop/chain.pem
    SSLCertificateKeyFile   /etc/ssl/shop/privkey.pem
    SSLProtocol             +TLSv1 +TLSv1.1 +TLSv1.2
    SSLHonorCipherOrder     on
    Header set Strict-Transport-Security "max-age=300"
</VirtualHost>
The four problems — try first, then click

1. SSLProtocol +TLSv1 +TLSv1.1 +TLSv1.2 — deprecated protocols enabled, TLS 1.3 missing.

TLS 1.0 and 1.1 were deprecated by RFC 8996 in 2021 and removed from browsers in 2020. There is also no -all, so this is adding to whatever was inherited. Should be SSLProtocol -all +TLSv1.2 +TLSv1.3.

2. SSLCertificateChainFile — obsolete since Apache 2.4.8 (2014).

Not broken, but it dates the config by a decade. Modern form: point SSLCertificateFile at fullchain.pem and delete the chain directive entirely.

3. Header set without always.

The HSTS header will be missing from redirects and error responses. Should be Header always set.

4. max-age=300 — five minutes.

Technically HSTS, practically useless. A browser forgets after five minutes, so almost every visit still starts with an insecure request. Production values are 31536000 (one year) or 63072000 (two). Five minutes is, however, exactly right while you are testing HSTS — see Part D1, where that is the recommended approach.

A fifth, arguable one: SSLHonorCipherOrder on has no effect in TLS 1.3 and is now generally set to off even for 1.2, since modern clients pick sensibly and often know better than the server which cipher performs best on their hardware.

🔑 What this exercise is really teaching: reading a TLS config is a skill in itself, and most of the problems you will find are age rather than error. Configs are copied forward for years, and a directive that was correct in 2016 is often the thing quietly holding a site back in 2026.

🎯 Interview questions — Apache

Q. What is SSLCertificateChainFile and should you use it?

It was Apache's directive for supplying intermediate certificates separately from the leaf. It became obsolete in Apache 2.4.8, when SSLCertificateFile was extended to load intermediates from the same file — so the modern form is simply to point SSLCertificateFile at fullchain.pem.

It still works, which is why it survives in so much documentation and so many copied configs.

What it tells you when you see it: the config was written for Apache 2.4.7 or earlier — 2014 or before. That is worth noticing, because whatever else is in that file is likely to be of the same vintage: probably SSLProtocol including TLS 1.0, probably a hand-written cipher string that has not been revisited.

The general point: most problems in a TLS config are age rather than mistakes. Directives get copied forward for a decade, and nobody re-reads them because nothing is failing.

---"

Part D · Hardening

D1 · HSTS, and the preload trap you cannot undo

The analogy — writing it in the customer's diary.

A redirect is a sign on the front door. The customer still walks to the front door first, reads the sign, then goes round the side. Every visit starts at the wrong door.

HSTS is different: the first time they visit, you say "write this in your diary — for the next year, come straight to the side entrance and do not even try the front." From then on their own diary sends them the right way, and they never approach the insecure door at all.

Two things follow immediately. It only works after the first visit — the very first approach is still to the front door. And you cannot un-write it from their diary; it is in their book, not yours, until the year is up.

javascript
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
DirectiveWhat it does
max-age=31536000Remember for one year, refreshed on every visit. In seconds
includeSubDomainsApplies to every subdomain too. ⚠️ Including ones you forgot about
preloadRequests inclusion in browsers' built-in list. ⚠️ Very hard to reverse
always (nginx)Send the header on error responses too, not just 2xx
The two ways HSTS bites, and both are self-inflicted.

includeSubDomains covers subdomains you have forgotten. An internal tool on legacy.example.com that only speaks HTTP becomes completely unreachable from any browser that has seen your header — not a warning, not a click-through, simply refused. And the ban lasts for max-age even after you remove the header.

preload is close to permanent. Once your domain is in the browsers' built-in list, removal "takes months for a change to reach users with a Chrome update", in hstspreload.org's own words — and they explicitly cannot make guarantees about other browsers. You are committing every current and future subdomain to HTTPS, for years, with no fast undo.

Preload is a good thing to do. It is not a thing to do on a Friday afternoon.

The safe rollout, and the reason for each step.
  1. max-age=300 — five minutes. If something breaks, it un-breaks itself in five minutes.
  2. max-age=86400 — a day. Watch for complaints from anything on a subdomain.
  3. Add includeSubDomains — but only after auditing every subdomain, including internal ones, staging, and anything a partner uses.
  4. max-age=31536000 — a year.
  5. Only then consider preload, if you are certain about every current and future subdomain.

Note step 1 deliberately uses the value flagged as wrong in Exercise C1.1. A five-minute max-age is a bad production setting and exactly the right testing setting — because the whole risk of HSTS is that mistakes are slow to undo, and a short max-age makes them fast.

🧪 Exercise D1.1 — Check HSTS on real sites and read what they have committed to
bash
for h in example.com github.com google.com; do
  printf '%-14s ' "$h"
  curl -sS -o /dev/null -D - "https://$h/" 2>/dev/null \
    | grep -i '^strict-transport-security' || echo "(none)"
done
Expected result — click to reveal
plain text
example.com    (none)
github.com     strict-transport-security: max-age=31536000; includeSubdomains; preload
google.com     strict-transport-security: max-age=31536000

What to read out of this.

  • GitHub has gone all the way: one year, all subdomains, preloaded. That is a deliberate, audited commitment — every *.github.com must be HTTPS forever, and they cannot quickly change their mind.
  • Google has one year but no includeSubDomains on this hostname. With an estate that size, committing every subdomain in one header would be reckless; they manage it per-host instead. A useful signal that includeSubDomains is a scope decision, not a strength decision.
  • example.com sends nothing — it is a documentation domain with nothing to protect.
  • Header names are case-insensitive and servers vary, which is why the grep -i matters. GitHub sends includeSubdomains with a lowercase d; that is fine, the directive is case-insensitive too.

🔑 Reading someone's HSTS header tells you how confident they are. max-age=300 means they are testing. A year plus includeSubDomains plus preload means they have audited every subdomain and accepted that they cannot easily reverse it. It is one of the few places a header reveals an organisation's operational maturity.

💡 To check whether a domain is actually in the preload list rather than merely requesting it, look it up at hstspreload.org — the preload directive is a request, not a confirmation. Sites frequently send it without ever having submitted.

🎯 Interview questions — HSTS

Q. What is HSTS and what would you be careful about?

A response header telling the browser to use HTTPS only for this host for max-age seconds, remembered locally and refreshed on every visit. It closes the gap a redirect leaves open: with only a redirect, every visit begins with a plaintext request that leaks the hostname and path and can be intercepted.

The critical limitation: it only helps after the first visit. The very first request to a host is still made in the clear. Preloading is what closes that.

The two things to be careful about, and both are self-inflicted:

  • includeSubDomains silently covers subdomains you have forgotten. An HTTP-only internal tool becomes completely unreachable from any browser that has seen the header — no click-through — and stays that way for max-age even after you remove it.
  • preload is close to irreversible. hstspreload.org states removal takes months to reach users via a Chrome update, with no guarantees for other browsers. You are committing every current and future subdomain, for years.

How I would roll it out: max-age=300 first, so mistakes undo themselves in five minutes; then a day; then add includeSubDomains only after auditing every subdomain including internal and staging; then a year; and only then consider preload.

And the nginx detail: add_header ... always, or the header is missing from redirects and error responses — exactly the responses an attacker might induce.


D2 · OCSP stapling

The analogy — the letter pinned next to the licence.

A customer wants to know your licence has not been revoked since it was issued. They could phone the licensing office themselves — slow, and it tells the office exactly which shops each customer is visiting.

Instead, you phone the office once every few hours, get a short signed letter saying "still valid as of this morning", and pin it next to your licence. Every customer reads it from your wall.

The letter is signed by the office, so you cannot forge it, and it is dated, so you cannot keep an old one indefinitely.

That is OCSP stapling: the server fetches its own revocation status and hands it to clients during the handshake.

javascript
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/ssl/shop/chain.pem;   # the intermediates, so nginx can verify the response
resolver 1.1.1.1 8.8.8.8 valid=300s;               # nginx needs DNS to reach the OCSP responder
Three things about the nginx configuration that are easy to get wrong.

All three defaults are off or absent. ssl_stapling defaults to off, ssl_stapling_verify defaults to off, and ssl_trusted_certificate has no default. Enabling stapling without the other two means nginx staples responses it has not verified.

ssl_trusted_certificate needs the intermediates, not the leaf and not the root — nginx uses it to validate the OCSP response it receives. This is the one place the separate chain.pem from Part A1 is actually needed.

Without a resolver, stapling silently does nothing. nginx does its own DNS for outbound requests and has no resolver configured by default. There is no error; stapling just never works. This is the commonest reason someone enables stapling and sees no change.

🧪 Exercise D2.1 — Check whether a site is stapling
bash
echo "=== a site that staples ==="
openssl s_client -connect github.com:443 -servername github.com -status </dev/null 2>/dev/null \
  | grep -A3 'OCSP response' | head -8

echo
echo "=== compare with one that may not ==="
openssl s_client -connect example.com:443 -servername example.com -status </dev/null 2>/dev/null \
  | grep -m1 -A1 'OCSP response'
Expected result — click to reveal

A site that staples:

plain text
OCSP response:
======================================
OCSP Response Data:
    OCSP Response Status: successful (0x0)
    Response Type: Basic OCSP Response
    Cert Status: good
    This Update: Aug 19 12:00:00 2026 GMT
    Next Update: Aug 26 12:00:00 2026 GMT

A site that does not:

plain text
OCSP response: no response sent

What to read out of this.

  • -status is the flag that asks for a stapled response. Without it you learn nothing about stapling.
  • Cert Status: good is the actual answer — the CA saying this certificate is not revoked.
  • This Update and Next Update are the dates on the letter. The response is only trusted within that window, which is why the server must keep re-fetching. A stapled response past its Next Update is treated as absent.
  • no response sent means stapling is not enabled, or it is enabled and quietly failing — which on nginx usually means a missing resolver.

🔑 Two things stapling buys you, and one it does not.

Speed. The client does not make a separate connection to the CA mid-handshake, which on a slow network is a visible delay.

Privacy. Without stapling, the client tells the CA which site it is visiting, every time. The CA becomes an unintended log of everyone's browsing.

It does not make revocation reliable. A server that simply stops stapling is treated as "no information available" by nearly every client, and the connection proceeds. An attacker with a revoked certificate just does not staple. That is Module 09's subject, and it is the reason the industry's real answer to revocation became short certificate lifetimes rather than better revocation checking.

🎯 Interview questions — OCSP stapling

Q. What is OCSP stapling and why enable it?

The server fetches its own OCSP response from the CA periodically and includes it in the TLS handshake, so the client does not have to contact the CA itself.

Two clear benefits: it removes a separate network round trip to the CA from the client's connection setup, and it removes a privacy leak — without stapling, the client tells the CA which site it is visiting on every connection, making the CA an unintended log of everyone's browsing.

The nginx specifics worth knowing: ssl_stapling on, ssl_stapling_verify on and ssl_trusted_certificate pointing at the intermediates — all three, because the last two default to off and absent. And a resolver directive, without which nginx cannot do the DNS lookup to reach the responder and stapling silently does nothing, with no error at all. That last one is the commonest reason someone enables stapling and sees no change.

The honest limitation: stapling improves speed and privacy but does not make revocation reliable. A stapled response is optional from the client's point of view, so an attacker holding a revoked certificate simply does not staple one, and nearly every client proceeds. Verify with openssl s_client -status.


D3 · Session tickets, and the forward-secrecy hole

The analogy — the stamped loyalty card.

Regulars get a stamped card so they can skip the ID check. Much faster for them, and less work for you (Module 06, D3).

The catch is the stamp. If you use the same stamp for three years, anyone who gets hold of it can forge cards — and can read every card you ever issued.

A session ticket is encrypted with a ticket key. Whoever holds that key can decrypt every session resumed with it. If the key never changes, one key compromise exposes months of traffic — which quietly undoes the forward secrecy you got from ephemeral key exchange.

nginx's default is ssl_session_tickets on, using a key generated at startup and never rotated for the life of the process. An nginx that has been running since a kernel update in March is protecting every resumed session since then with a single key.

And behind a load balancer it is worse in the other direction: each nginx generates its own key, so a client resuming against a different backend gets a ticket nobody else can decrypt, and silently falls back to a full handshake. Resumption appears to be enabled and mostly does not work.

OptionWhen it is right
ssl_session_tickets off;The simple, safe default. Use ssl_session_cache for resumption instead. Right for most single-server deployments
ssl_session_ticket_key with rotated key filesMulti-server fleets that need resumption across backends — rotate hourly, keep two or three generations
Leave the default on, unrotated⚠️ Never a deliberate choice, and extremely common
🧪 Exercise D3.1 — Check what resumption a server actually offers
bash
echo "=== TLS 1.2 resumption against a real site ==="
openssl s_client -connect github.com:443 -servername github.com -tls1_2 -reconnect </dev/null 2>/dev/null \
  | grep -iE '^(New|Reused)'

echo
echo "=== does it issue session tickets? ==="
openssl s_client -connect github.com:443 -servername github.com </dev/null 2>/dev/null \
  | grep -iE 'TLS session ticket|Session-ID:' | head -3
Expected result — click to reveal
plain text
=== TLS 1.2 resumption against a real site ===
New, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256
Reused, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256
Reused, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256
Reused, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256
Reused, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256
Reused, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256

=== does it issue session tickets? ===
TLS session ticket lifetime hint: 7200 (seconds)
TLS session ticket:
Session-ID: A81F...

What to read out of this.

  • One New then five Reused — resumption is working, exactly as in Module 06 (D3.1). If every line said New, resumption is broken, and behind a load balancer that usually means mismatched ticket keys.
  • TLS session ticket lifetime hint: 7200 — the server suggests two hours. That is a hint, not a guarantee; the server can refuse to resume at any point.
  • Both a ticket and a Session-ID are present. Servers commonly offer both mechanisms, and the client chooses. Turning tickets off does not turn resumption off, as long as ssl_session_cache is configured.

🔑 The diagnostic for a fleet: run this repeatedly against a load-balanced hostname. If you see New far more often than you expect, the backends are not sharing resumption state — mismatched ticket keys, or ssl_session_cache set per-server with no shared store. It is a pure performance loss and completely invisible unless you look for it.

🎯 Interview questions — Session tickets

Q. What is the security concern with TLS session tickets?

A session ticket is the session state, encrypted with a ticket key held by the server. Anyone who obtains that key can decrypt every session resumed with it — which undermines the forward secrecy you got from ephemeral key exchange, because the ephemeral keys no longer protect resumed sessions.

The risk is entirely about rotation. nginx enables tickets by default with a key generated at startup and never rotated for the life of the process, so a long-running server can be protecting months of resumed sessions with one static key.

The two workable answers:

  • Single server: ssl_session_tickets off and use ssl_session_cache instead. Simple, and resumption still works.
  • A fleet: keep tickets, but manage ssl_session_ticket_key explicitly — rotate hourly, keep two or three generations so in-flight tickets still decrypt, and distribute the keys to every backend.

The operational failure that comes with the same setting: behind a load balancer, each server generating its own key means a client resuming against a different backend silently falls back to a full handshake. Resumption looks enabled and mostly does not happen — a pure performance loss that nothing alerts on. openssl s_client -reconnect against the load-balanced name is how you spot it.

---"

Part E · Putting it together

E1 · How this all fits — the complete picture

Diagram source
flowchart TD
    subgraph DISK["📁 ON DISK"]
        FC["fullchain.pem<br>leaf FIRST, then intermediates<br>0644 · public"]
        PK["privkey.pem<br>0600 root:root<br>NEVER travels"]
        CH["chain.pem<br>intermediates only<br>for OCSP stapling"]
    end
    subgraph CONF["⚙️ CONFIG"]
        C1["ssl_certificate → fullchain.pem"]
        C2["ssl_certificate_key → privkey.pem"]
        C3["ssl_session_cache · tickets off<br>stapling on · HSTS"]
    end
    FC --> C1
    PK --> C2
    CH --> C3
    CONF --> RELOAD{"🔄 RELOAD<br>after every renewal"}
    RELOAD --> WIRE["🌐 WHAT THE SERVER SERVES"]
    WIRE --> CHECK["✅ VERIFY THE WIRE<br>not the file"]
    RELOAD -.->|"skipped"| BUG["💥 file updated,<br>process still serving<br>the OLD certificate"]
    style PK fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
    style BUG fill:#ffcccc,stroke:#cc0000,stroke-width:2px
    style CHECK fill:#d5e8d4,stroke:#82b366,stroke-width:2px

Three files, four config lines that matter, and one step everybody forgets.

The red box is the module's central failure: a renewal writes a new file, nothing reloads, and the running process keeps serving the old certificate until it expires — weeks later, with nothing connecting the two events. The green box is the fix: verify what is on the wire, never what is on the disk.


E2 · Production practice

HabitWhy
openssl x509 -in fullchain.pem -noout -subject before every deployReads only the first certificate — exactly what nginx does. If it is not your site's name, the file is reversed
grep -c 'BEGIN CERTIFICATE' fullchain.pem — expect 2 or 3, never 1A count of 1 means an incomplete chain: works in browsers, fails in curl, Java and Go
Key 0600 root:root, certificate 0644The server reads the key as root then drops privileges. Workers never need it, so do not give it to them
certbot renew --deploy-hook "systemctl reload nginx"--deploy-hook fires only on actual renewal; --post-hook reloads twice a day for nothing
Reload, never restartReload re-reads certificates and lets in-flight requests finish. Restart drops connections for no benefit
Compare the fingerprint on disk with the fingerprint on the wireThe only check that catches "renewed but never reloaded" — weeks before the expiry outage
Set ssl_session_cache explicitly — the default is noneOut of the box nginx does no session-ID resumption at all. One line, pure performance win
ssl_session_tickets off unless you rotate ticket keysDefault is on with a key that never rotates — a forward-secrecy hole, and broken across a fleet anyway
301 redirects, one hop, $host and $request_uri preserved302 leaves a plaintext request on every visit; $server_name silently rewrites the hostname
Never redirect /.well-known/acme-challenge/If HTTPS breaks, renewal depends on HTTPS, and you cannot renew your way out of it
Roll HSTS out at max-age=300 first, and audit subdomains before includeSubDomainsMistakes take max-age to undo. Preload takes months and cannot be guaranteed across browsers
nginx needs a resolver for OCSP staplingWithout it stapling silently does nothing, with no error at all

E3 · Capstone exercise

Write the pre-deploy gate. It checks everything that can be checked before a certificate goes live, then everything that can only be checked after, and it exercises every part of this module.

Brief. Write tlsdeploy-check that takes a certificate directory and, optionally, a live hostname, and:

  1. Pre-deploy (files only, no server needed): leaf is first in the bundle; chain has ≥ 2 certificates; key matches certificate; key is unencrypted; key is 0600; certificate is not expiring within 30 days
  2. Post-deploy (against the live host): the fingerprint on the wire matches the fingerprint on disk — catching "renewed but never reloaded"
  3. Report the HSTS header, and warn if max-age is under a year or the header is missing
  4. Report whether OCSP stapling is working
  5. Exit non-zero if any pre-deploy check fails, so it can gate a pipeline
Model answer — attempt it first, then click
bash
#!/usr/bin/env bash
# tlsdeploy-check <certdir> [live-hostname]
#   certdir must contain fullchain.pem and privkey.pem
set -uo pipefail
dir="${1:?usage: tlsdeploy-check <certdir> [hostname]}"; host="${2:-}"
FC="$dir/fullchain.pem"; PK="$dir/privkey.pem"
fail=0
ok()   { printf '  ✅ %-26s %s\n' "$1" "$2"; }
bad()  { printf '  ❌ %-26s %s\n' "$1" "$2"; fail=1; }
warn() { printf '  ⚠️  %-26s %s\n' "$1" "$2"; }

printf '\n  PRE-DEPLOY  (%s)\n  %s\n' "$dir" "$(printf '=%.0s' {1..58})"

[ -r "$FC" ] || { bad "fullchain.pem" "missing or unreadable"; }
[ -r "$PK" ] || { bad "privkey.pem"   "missing or unreadable"; }
[ "$fail" -eq 1 ] && exit 1

# 1. leaf first?
subj=$(openssl x509 -in "$FC" -noout -subject 2>/dev/null | sed 's/^subject=//')
bc=$(openssl x509 -in "$FC" -noout -ext basicConstraints 2>/dev/null | tail -1 | tr -d ' ')
case "$bc" in
  *CA:TRUE*) bad "bundle order" "FIRST cert is a CA - file is REVERSED ($subj)" ;;
  *)         ok  "bundle order" "leaf first: $subj" ;;
esac

# 2. chain complete?
n=$(grep -c 'BEGIN CERTIFICATE' "$FC")
if [ "$n" -lt 2 ]; then
  bad "chain length" "$n certificate(s) - intermediates MISSING"
else
  ok "chain length" "$n certificates"
fi

# 3. key matches cert?
if diff -q <(openssl x509 -in "$FC" -noout -pubkey 2>/dev/null) \
           <(openssl pkey -in "$PK" -pubout 2>/dev/null) >/dev/null 2>&1; then
  ok "key ↔ certificate" "match"
else
  bad "key ↔ certificate" "MISMATCH - the service will refuse to start"
fi

# 4. key usable unattended?
case "$(head -1 "$PK")" in
  *ENCRYPTED*) bad "key encryption" "passphrase-protected - service will hang at boot" ;;
  *)           ok  "key encryption" "unencrypted, starts unattended" ;;
esac

# 5. key permissions
perms=$(stat -c '%a' "$PK" 2>/dev/null || stat -f '%Lp' "$PK")
case "$perms" in
  600|400) ok  "key permissions" "$perms" ;;
  *)       bad "key permissions" "$perms - must be 600 or 400" ;;
esac

# 6. expiry, across the WHOLE bundle
d=0; tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
( cd "$tmp" && csplit -sz -f c- -b '%02d.pem' "$FC" '/BEGIN CERTIFICATE/' '{*}' )
for c in "$tmp"/c-*.pem; do
  end=$(openssl x509 -in "$c" -noout -enddate | cut -d= -f2)
  if   ! openssl x509 -in "$c" -noout -checkend 0       >/dev/null; then bad  "expiry depth=$d" "EXPIRED $end"
  elif ! openssl x509 -in "$c" -noout -checkend 2592000 >/dev/null; then warn "expiry depth=$d" "under 30 days: $end"
  else ok "expiry depth=$d" "$end"; fi
  d=$((d+1))
done

[ -z "$host" ] && { printf '\n  %s\n\n' "$([ $fail -eq 0 ] && echo 'PRE-DEPLOY PASSED' || echo 'PRE-DEPLOY FAILED')"; exit $fail; }

# ---------- post-deploy ----------
printf '\n  POST-DEPLOY  (%s)\n  %s\n' "$host" "$(printf '=%.0s' {1..58})"
S="openssl s_client -connect ${host}:443 -servername ${host}"

wire=$($S </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256 2>/dev/null)
disk=$(openssl x509 -in "$FC" -noout -fingerprint -sha256)
if [ -z "$wire" ]; then
  bad "live connection" "could not retrieve a certificate"
elif [ "$wire" = "$disk" ]; then
  ok "disk ↔ wire" "in sync"
else
  bad "disk ↔ wire" "MISMATCH - the service has NOT been reloaded since renewal"
fi

nsent=$($S -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERTIFICATE')
[ "$nsent" -ge 2 ] && ok "chain served" "$nsent certificates" \
                   || bad "chain served" "$nsent - browsers may hide this; curl/Java will fail"

hsts=$(curl -sS -o /dev/null -D - "https://$host/" 2>/dev/null \
       | grep -i '^strict-transport-security' | tr -d '\r')
if [ -z "$hsts" ]; then
  warn "HSTS" "no header"
else
  ma=$(printf '%s' "$hsts" | grep -oiE 'max-age=[0-9]+' | cut -d= -f2)
  [ "${ma:-0}" -ge 31536000 ] && ok "HSTS" "max-age=$ma" || warn "HSTS" "max-age=$ma (under 1 year)"
fi

staple=$($S -status </dev/null 2>/dev/null | grep -m1 'OCSP response')
case "$staple" in
  *"no response sent"*) warn "OCSP stapling" "not stapling" ;;
  "")                   warn "OCSP stapling" "not stapling" ;;
  *)                    ok   "OCSP stapling" "stapled" ;;
esac

printf '\n  %s\n\n' "$([ $fail -eq 0 ] && echo 'ALL CHECKS PASSED' || echo 'CHECKS FAILED')"
exit $fail

Try it:

bash
chmod +x tlsdeploy-check
cd ~/tls-lab/m05
./tlsdeploy-check .                       # pre-deploy only
./tlsdeploy-check . example.com           # both halves

The five design decisions worth understanding.

1. It detects a reversed bundle by reading basicConstraints, not by name matching. If the first certificate says CA:TRUE, the file is reversed — that works for any site without knowing what the hostname should be. Comparing against an expected name would need configuration; this does not.

2. Expiry is checked at every depth, by splitting the bundle. An expired intermediate behind a valid leaf is invisible to any check that only reads the first certificate — and openssl x509 -in fullchain.pem reads exactly that one.

3. The disk-versus-wire comparison is the point of the post-deploy half. It is the only check that catches "renewed but never reloaded", and it catches it weeks before the expiry outage rather than during it.

4. HSTS and stapling are warnings, not failures. Neither is required for a working deployment, and a gate that fails the build for a missing HSTS header will simply be disabled by whoever is trying to ship. Reserve failures for things that are actually broken.

5. It exits non-zero on pre-deploy failure so a pipeline can gate on it. All six pre-deploy checks run on files alone — no server, no network — so this can run in CI before anything is deployed anywhere. Four of the six failures it catches have completely different symptoms, and two of them would not surface until a restart days later.

Where this goes next: add revocation status (Module 09), certificate transparency monitoring (Module 11), and full protocol and cipher auditing (Module 13). Together with certinfo, tlsinfo and tlsdiag from Modules 03, 06 and 07, this is most of a real certificate operations toolkit.


E4 · Official documentation — what to bookmark and how to read it

The single most useful page for this module: nginx ngx_http_ssl_module. Every directive, with its default printed next to it.

Make it a reflex: read the default before you write the directive. Half of this module's advice — set ssl_session_cache, turn ssl_session_tickets off, ssl_prefer_server_ciphers is already off — comes from noticing what nginx does when you say nothing.

Core reference pages

LinkWhat it is for
nginx ngx_http_ssl_moduleEvery ssl_* directive with its default and the version it appeared in
nginx — Configuring HTTPS serversThe narrative version: SNI, chain handling, common mistakes, performance
nginx — Controlling nginxWhat reload actually does, and why it does not drop connections
Apache mod_sslEvery SSL* directive, including the SSLCertificateChainFile obsolescence note
Apache SSL/TLS How-ToTask-oriented recipes rather than directive-by-directive reference
TLSRef — server-side TLS · TLS ConfiguratorMaintained configs for nginx, Apache, HAProxy and more. Copy from here rather than hand-writing ciphers
RFC 6797 — HSTS · hstspreload.orgThe header, and the submission requirements plus the removal warning
certbot — renewal and hooks--deploy-hook versus --post-hook, and why the difference matters
SSL Labs Server TestAn external opinion on the whole deployment — chain, protocols, HSTS, stapling

How to read the nginx directive reference

Every directive page has the same four lines, and reading them in this order saves time:

plain text
Syntax:   ssl_session_cache off | none | [builtin[:size]] [shared:name:size];
Default:  ssl_session_cache none;          <- READ THIS FIRST. It is often not what you assume
Context:  http, server                     <- where it is legal. Some are server-level only
This directive appeared in version 1.x.x   <- will it work on the nginx you actually have?
  1. Default first. ssl_session_cache none and ssl_session_tickets on are both surprising, and both matter.
  2. Context second. Apache's SSLStaplingCache and SSLSessionCache are server-level only — putting them in a <VirtualHost> fails to start.
  3. Version third. http2 on; needs nginx 1.25.1+; before that it is listen 443 ssl http2;. Copying a config forward across a version boundary is a common source of confusing errors.

The offline alternative

bash
nginx -t                              # ⭐ validate config - ALWAYS before a reload
nginx -T                              # ⭐ dump the FULLY RESOLVED config, all includes
nginx -V 2>&1 | tr ' ' '\n' | grep -i ssl   # which TLS features this build has
apachectl configtest                  # Apache equivalent of nginx -t
apachectl -S                          # Apache's resolved virtual host map
apache2ctl -M | grep ssl              # is mod_ssl actually loaded?
🧪 Exercise E4.1 — Find a directive's default without a browser
bash
# If nginx is installed, its docs ship with it on many systems:
ls /usr/share/nginx/html 2>/dev/null
man nginx 2>/dev/null | head -20

# The reliable offline route: check what the binary supports
nginx -V 2>&1 | tr ' ' '\n' | grep -E 'with-http_(ssl|v2)|openssl'
Expected result — click to reveal
plain text
--with-http_ssl_module
--with-http_v2_module
--with-openssl=/build/nginx/openssl-3.0.13

What to read out of this.

  • nginx -V is the honest answer to "does this nginx support X". The documentation describes nginx in general; -V describes your build. A directive documented on nginx.org will fail if the module was not compiled in.
  • --with-openssl= tells you which OpenSSL nginx is linked against, which is not necessarily the openssl binary on your $PATH. That matters for TLS 1.3, for ssl_early_data, and — as Module 06 (D4) showed — for whether post-quantum key exchange is available at all.
  • --with-http_v2_module confirms HTTP/2 support. Without it, http2 on; is an unknown directive and nginx refuses to start.

💡 nginx does not ship its directive reference offline, unlike OpenSSL's man pages. The practical answer is to keep nginx.org/en/docs bookmarked, and to remember that nginx -T tells you what your running configuration resolved to — which is usually the question you actually have.


E5 · Self-assessment

Answer each out loud before opening it. If your answer is materially thinner than the one behind the toggle, that topic is worth a second pass.

1. Which files does a web server need, and which is secret?

fullchain.pem (leaf then intermediates — public, 0644) and privkey.pem (secret, 0600 root:root). A separate chain.pem of intermediates only is needed for OCSP stapling.

The root is never served: a client that trusts it has it, and one that does not will not trust it because a server sent it.

2. Does the order of certificates in the bundle matter?

Not to the client's path building — it searches, so any order verifies. It matters to the server, which reads the file positionally: nginx treats the first certificate as the leaf. Reverse it and nginx serves the intermediate as your certificate, producing hostname mismatch on every client.

Check with openssl x509 -in fullchain.pem -noout -subject — that reads only the first certificate, exactly as nginx does.

3. Should the private key be readable by www-data?

No. nginx and Apache start as root, read the key into memory, then drop privileges. The workers never need it. Making it worker-readable means any file-read vulnerability in the web application exposes the private key, for no benefit.

0600 root:root, in a 0700 root:root directory.

4. A certificate was renewed weeks ago and the site just went down. Why?

The service was never reloaded. It read its certificate at startup and kept serving the old one from memory until it expired. The renewal logged success, the file on disk was correct, and file-based monitoring saw nothing.

Fix: certbot renew --deploy-hook "systemctl reload nginx", and monitor the live endpoint, comparing the fingerprint on the wire with the one on disk.

5. Reload or restart, and why?

Reload. nginx's master re-reads config and certificates, starts new workers, and lets old workers finish in-flight requests before exiting — no dropped connections. Restart achieves the same certificate update while dropping connections, so there is no reason to prefer it.

nginx -s reload or systemctl reload nginx; Apache is apachectl -k graceful.

6. What is wrong with return 302 and with $server_name in a redirect?

302 is temporary, so the browser tries HTTP again on every visit — leaving a plaintext request on the wire each time. 301 lets it remember.

$server_name is the first name in the server_name list, so a request for www.example.com gets silently redirected to example.com. $host preserves what the client asked for. And use $request_uri, not $uri, so the query string survives.

7. Which path must never be redirected to HTTPS, and why?

/.well-known/acme-challenge/. The CA fetches it over plain HTTP during an HTTP-01 challenge. If HTTPS is broken or the certificate has expired, redirecting there means renewal depends on the very thing that is broken — and you cannot renew your way out of it.

8. What is HSTS, and what are the two dangerous options?

A header telling the browser to use HTTPS only for this host for max-age seconds. It closes the gap a redirect leaves — with only a redirect, every visit starts with a plaintext request.

includeSubDomains covers subdomains you have forgotten, making HTTP-only internal tools unreachable with no click-through, for the full max-age even after you remove the header. preload is close to irreversible — removal takes months to reach users and is not guaranteed across browsers.

Roll out with max-age=300 first so mistakes undo themselves in five minutes.

9. What does OCSP stapling buy you, and what does it not?

It buys speed — no separate client connection to the CA — and privacy, because otherwise the client tells the CA which site it is visiting on every connection.

It does not make revocation reliable: a stapled response is optional, so an attacker with a revoked certificate simply does not staple one and clients proceed.

nginx needs ssl_stapling on, ssl_stapling_verify on, ssl_trusted_certificate pointing at the intermediates, and a resolver — without which it silently does nothing.

10. What is the concern with TLS session tickets?

Tickets are encrypted with a server-held key; whoever obtains it can decrypt every session resumed with it, undermining forward secrecy. nginx's default is tickets on with a key generated at startup and never rotated.

Single server: turn tickets off and use ssl_session_cache. A fleet: manage ssl_session_ticket_key explicitly with hourly rotation and a couple of generations retained — and note that unmanaged keys behind a load balancer break resumption entirely, silently.

11. Which two nginx defaults are surprising?

ssl_session_cache none — no session-ID resumption at all out of the box, a pure performance loss fixed by one line. And ssl_session_tickets on — resumption is enabled by default, but via the mechanism with the forward-secrecy caveat and an unrotated key.

So the defaults give you the mechanism with the security problem and not the one without it. Set both explicitly.

12. SSLCertificateChainFile — what does seeing it tell you?

That the config was written for Apache 2.4.7 or earlier — the directive became obsolete in 2.4.8, released in 2014, when SSLCertificateFile was extended to load intermediates from the same file.

It still works, so it is not urgent. But it dates the whole file, and whatever else is in there is likely the same vintage — probably TLS 1.0 enabled and a hand-written cipher string nobody has revisited.


E6 · Command reference — everything from this module

The commands introduced in Module 08, grouped by what you are trying to achieve. The ones marked ⭐ are genuinely daily-use.

Pre-deploy file checks — run all five

bash
openssl x509 -in fullchain.pem -noout -subject                  # ⭐ 1. leaf first? (reads only cert #1)
grep -c 'BEGIN CERTIFICATE' fullchain.pem                       # ⭐ 2. chain complete? expect 2-3
diff <(openssl x509 -in fullchain.pem -noout -pubkey) \
     <(openssl pkey -in privkey.pem -pubout)                    # ⭐ 3. key matches certificate?
head -1 privkey.pem                                             # ⭐ 4. unencrypted? starts unattended?
stat -c '%a %n' privkey.pem fullchain.pem                       # ⭐ 5. 600 and 644

Assemble the files

bash
cat leaf.crt intermediate.crt > fullchain.pem                   # ⭐ leaf FIRST, no root
cp leaf.key privkey.pem && chmod 600 privkey.pem                # ⭐
cp intermediate.crt chain.pem                                   # for ssl_trusted_certificate

Validate and reload a server

bash
nginx -t                                                        # ⭐ ALWAYS before reloading
nginx -T | grep -E 'ssl_|server_name|listen'                    # ⭐ fully resolved config
nginx -s reload            # or: systemctl reload nginx         # ⭐ no dropped connections
apachectl configtest                                            # Apache: validate
apachectl -k graceful      # or: systemctl reload apache2       # Apache: reload
apachectl -S                                                    # Apache: resolved vhost map

Verify the LIVE endpoint, not the file

bash
openssl s_client -connect h:443 -servername h </dev/null 2>/dev/null \
  | openssl x509 -noout -fingerprint -sha256                    # ⭐ what is ON THE WIRE
openssl x509 -in fullchain.pem -noout -fingerprint -sha256      # ⭐ what is ON DISK - compare them
openssl s_client -connect h:443 -servername h -showcerts </dev/null 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'                                 # ⭐ did it serve the chain?
openssl s_client -connect h:443 -servername h -status </dev/null 2>/dev/null \
  | grep -A5 'OCSP response'                                    # ⭐ is it stapling?

Headers and redirects

bash
curl -sS -o /dev/null -D - http://h/ | head -5                             # ⭐ 301? Location correct?
curl -sSL -o /dev/null -w '%{url_effective} %{num_redirects}\n' http://h/  # ⭐ one hop?
curl -sS -o /dev/null -D - https://h/ | grep -i strict-transport           # ⭐ HSTS
curl -sS -o /dev/null -D - "http://h/a/b?c=1" | grep -i '^location:'       # path+query preserved?

Renewal hooks

bash
certbot renew --deploy-hook "systemctl reload nginx"    # ⭐ fires ONLY on actual renewal
certbot renew --dry-run                                 # ⭐ test the whole flow, issues nothing
certbot certificates                                    # what certbot thinks it manages
The deploy-day sequence. Nothing here is optional, and it takes about ten seconds:
bash
# BEFORE
openssl x509 -in fullchain.pem -noout -subject          # leaf first?
grep -c 'BEGIN CERTIFICATE' fullchain.pem               # chain complete?
diff <(openssl x509 -in fullchain.pem -noout -pubkey) <(openssl pkey -in privkey.pem -pubout)
nginx -t                                                # config valid?

# DEPLOY
nginx -s reload

# AFTER
diff <(openssl s_client -connect h:443 -servername h </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256) \
     <(openssl x509 -in fullchain.pem -noout -fingerprint -sha256) && echo "wire matches disk"

Four checks, one reload, one confirmation. The last line is the one people skip, and it is the one that catches the outage.


Next — Module 09 · Revocation: CRL, OCSP, Stapling & Why It's Broken.

Stapling appeared twice in this module with a caveat attached both times: it improves speed and privacy but does not make revocation reliable. Module 09 explains why — CRLs and their size problem, OCSP and its privacy and latency problems, soft-fail versus hard-fail and why browsers chose soft-fail, CRLite and CRLSets as the browsers' actual answer, and the conclusion the industry reached: that since revocation cannot be made to work, certificates must simply not live long enough to need it.

That conclusion is what drives everything in Module 10.

Official reading ahead of it: RFC 6960 — OCSP and RFC 5280 §5 — CRL Profile.

📚 Sources for the interview questions

Question selection was cross-referenced against publicly published 2026 SSL/TLS and web-server interview question sets, then rewritten and deepened:

Every configuration directive, default value and version note in this module was verified against the official documentationnginx ngx_http_ssl_module and Apache mod_ssl — rather than written from memory. That includes the two surprising nginx defaults (ssl_session_cache none and ssl_session_tickets on), the fact that ssl_prefer_server_ciphers already defaults to off, and Apache's own wording that SSLCertificateChainFile "became obsolete with version 2.4.8". The HSTS preload requirements and the removal warning are quoted from hstspreload.org.

The file-level exercises — chain order, key matching, permissions, and the disk-versus-wire fingerprint comparison — were executed on OpenSSL 3.0.13. nginx itself was not run for this module, so the configuration blocks are documentation-verified rather than execution-verified; the exercises are deliberately written to need only openssl.

Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what actually differentiates a candidate in the room.

Spotted a mistake or want something added? Send me a note.