Hosting & Deployment Web Security

TLS Certificates: The Chain, the Renewal, and the Reload People Forget

Published

Certificates are free and automated, which is why the remaining failures are so consistent: a missing intermediate that only breaks on some clients, and a renewal cron that fetches a new certificate the server never loads. Here is how to verify both from the command line.

HTTPS used to be a purchase, a paperwork exercise, and an annual reminder in someone's calendar. Now it is free and automatic, which has moved the failure modes rather than removing them. The two that account for most real outages are both silent: a certificate chain that validates in your browser and fails in someone else's, and a renewal that completes perfectly while the server keeps serving the old certificate until it expires.

What a certificate actually proves

Only one thing: that whoever controls this connection also controls this domain name. That is it. It is not a statement that the site is trustworthy, well-run, or not a scam — a phishing site can have a perfectly valid certificate, and most do.

What it gives you is real and worth having: traffic that cannot be read or modified in transit, and confidence that you are talking to the host you asked for. That is the foundation everything else sits on, including HTTP/2 and HTTP/3, which browsers will not negotiate without it.

The chain, and why it breaks asymmetrically

Trust is delegated in a chain, and your server has to hand over the middle of it:

Root CA               in the browser's trust store already
    |  signs
Intermediate CA       YOU must send this
    |  signs
your certificate      YOU must send this

Browsers ship root certificates. They do not ship intermediates. So if you serve only your own certificate, the client has a leaf it cannot connect to any root it trusts.

Here is what makes this so nasty: some clients paper over it. Many browsers will fetch a missing intermediate on their own, or reuse one they cached from another site. So the developer's browser says the padlock is fine, while a mobile app, a payment webhook, a Java client, or curl on a fresh container all reject the connection. "Works in my browser, fails in production integrations" is almost always a missing intermediate.

The fix is configuration, not a new certificate — point your server at the full chain file (fullchain.pem from Let's Encrypt) rather than the certificate alone. This is the single most common TLS misconfiguration and it takes one line to correct.

Verify from the command line

Your browser is the wrong tool for this because it is too forgiving. Ask openssl, which is not:

# dates, subject, issuer
echo | openssl s_client -connect example.com:443 \
    -servername example.com 2>/dev/null \
  | openssl x509 -noout -dates -subject -issuer
# the whole chain the server sent, and the verify result
echo | openssl s_client -connect example.com:443 \
    -servername example.com -showcerts 2>/dev/null \
  | grep -E 's:|i:|Verify return code'

You want at least two certificates listed and Verify return code: 0 (ok). One certificate means the intermediate is missing, whatever your browser says.

The -servername flag matters: it sends SNI, which is how one IP address serves many hostnames. Without it you get whatever the server considers its default site, which may be a completely different certificate — and a confusing five minutes.

Days until expiry, suitable for a monitoring script:

end=$(echo | openssl s_client -connect example.com:443 \
        -servername example.com 2>/dev/null \
      | openssl x509 -noout -enddate | cut -d= -f2)
echo $(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 )) days left

Let's Encrypt and the 90-day clock

Certificates are valid for 90 days, and that short lifetime is deliberate: it forces automation, and automation is more reliable than a calendar reminder. Renew at 60 days, leaving a 30-day margin for something to go wrong and for you to notice.

Three ways to prove you control the domain:

ACME challenge types
ChallengeHow it worksUse when
HTTP-01Serves a token at /.well-known/acme-challenge/ over port 80The default. Simplest, needs port 80 reachable.
DNS-01Publishes a TXT recordRequired for wildcards. Also works with no public web server.
TLS-ALPN-01Negotiated during the TLS handshake on 443Port 80 is blocked.

Two traps around HTTP-01. Your redirect rules must not intercept /.well-known/acme-challenge/ — a blanket HTTP-to-HTTPS redirect is fine (the client follows it), but a catch-all rewrite into your PHP router will swallow the token. And if you serve a wildcard certificate, HTTP-01 cannot issue it at all; that is DNS-01 only, which means your renewal needs API access to your DNS provider.

An exclusion, if you route everything through a front controller:

RewriteEngine On

# let ACME through untouched, before anything else
RewriteRule ^\.well-known/acme-challenge/ - [L]

RewriteCond %{HTTPS} !=on
RewriteRule ^(.*)$ https://example.com/$1 [R=301,L]

The ACME exclusion goes above whatever HTTPS redirect you already have. Below it, the challenge request gets bounced to HTTPS on a host whose certificate has just expired, and the renewal fails for the exact reason it was running.

The schedule itself is the easy part to get subtly wrong; our cron expression generator shows the next few run times for whatever you type, which is how you catch a job set to run once a month by accident.

The reload nobody remembers

This is the failure I would bet on if a site goes down on a certificate expiry date. The renewal ran. The new certificate is on disk, correct and valid. And the web server is still holding the old one in memory, because a running process does not reread its certificate files.

# certbot: run a reload after a successful renewal
certbot renew --deploy-hook "systemctl reload nginx"

--deploy-hook fires only when a certificate was actually renewed, which is what you want — renew runs harmlessly most days and does nothing. Test the whole path without waiting 60 days:

certbot renew --dry-run

Then verify from outside, which is the only check that counts:

# does the served certificate match the one on disk?
echo | openssl s_client -connect example.com:443 \
    -servername example.com 2>/dev/null \
  | openssl x509 -noout -fingerprint -sha256

openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem \
  -noout -fingerprint -sha256

Two identical fingerprints means the reload happened. Different fingerprints means you have found your future outage while you still have weeks to fix it.

On managed and shared hosting this is the host's job and usually works. Verify it anyway, once, with the command above.

Protocol and cipher configuration

Modern and boring:

  • TLS 1.3 preferred, TLS 1.2 as the floor. TLS 1.0 and 1.1 are formally deprecated and no current browser needs them.
  • Do not hand-write cipher lists. They age badly and a subtle mistake is worse than a default. Use a generated configuration from Mozilla's SSL Configuration Generator and pick the "intermediate" profile unless you have a specific reason not to.
  • Revocation checking is in flux. OCSP stapling was standard advice for years; certificate authorities including Let's Encrypt have been moving away from OCSP toward certificate revocation lists. Check your CA's current guidance rather than copying a config from 2019.

Grade it with SSL Labs, which tests your chain against a long list of real client implementations rather than just the one on your desk.

After you have HTTPS working

  1. Redirect HTTP to HTTPS in a single hop, combined with host normalisation rather than chained after it.
  2. Add HSTS so browsers stop trying HTTP at all. Start with a short max-age.
  3. Fix mixed content. An HTTPS page loading an HTTP script gets the script blocked outright. Grep your templates for http:// and use protocol-relative or absolute HTTPS URLs.
  4. Monitor expiry externally. A check that runs on the same box as the renewal cannot tell you that box is down.

The five-minute audit

  1. openssl s_client -showcerts: at least two certificates, verify code 0.
  2. Days until expiry: more than 30.
  3. Served fingerprint matches the file on disk.
  4. certbot renew --dry-run succeeds, with a deploy hook that reloads the server.
  5. SSL Labs reports no protocol or chain warnings.

More reading