Skip to content
Go back

Reverse Proxy SSL: The Cert Chain Mistake Everyone Makes

By SumGuy 4 min read
Reverse Proxy SSL: The Cert Chain Mistake Everyone Makes

The Problem You Don’t Know You Have

You’ve got a nice reverse proxy setup. NGINX, HAProxy, Caddy — doesn’t matter. You grabbed an SSL cert from Let’s Encrypt, threw it in, and figured you were done. Things mostly work. Some connections fail mysteriously. Mobile phones hit it weirdly. Your mom’s browser times out. You blame the internet.

Here’s the thing: you’re probably only serving the leaf certificate, not the full chain. And browsers are way pickier about this than you think.

Why This Happens

When you get a cert from Let’s Encrypt (or any CA), you actually get three pieces:

  1. Your leaf cert — the one with your domain name
  2. The intermediate cert — signed by Let’s Encrypt’s intermediate
  3. The root cert — signed by a trusted root authority

Your reverse proxy needs to send items 1 and 2 to the client. The client already has the root in its trust store, so it doesn’t need that.

If you only send the leaf cert, the client has to figure out the chain on its own. Some clients do this fine (modern desktops). Others bail immediately (some mobile phones, older browsers, API clients). That’s why your production app works fine for you but your mom can’t visit.

How It Looks in NGINX

Here’s the rookie config:

server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/cert.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# ... rest of config
}

That cert.pem is just the leaf. Your browser has to hunt down the intermediate, and if it can’t reach it (offline, bad network, whatever), the connection dies.

The fix is stupid simple:

server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# ... rest of config
}

Use fullchain.pem, not cert.pem. Done. That file already has the leaf + intermediate bundled.

Checking If You’ve Got It Wrong

Want to verify your reverse proxy is sending the chain? Use openssl:

Terminal window
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | grep -A 20 "Certificate chain"

You should see something like:

Certificate chain
0 s:CN = example.com
i:C = US, O = Let's Encrypt, CN = R3
1 s:C = US, O = Let's Encrypt, CN = R3
i:C = US, O = Internet Security Research Group, CN = ISRG Root X1

If you only see one cert in that output, you’re missing the intermediate. Fix it.

Other Reverse Proxies

HAProxy:

ssl-default-bind-options ssl-min-ver TLSv1.2
cert /etc/ssl/certs/fullchain.pem

Caddy: Caddy handles this automatically. If you’re using Let’s Encrypt, it builds the chain for you. No config needed.

Traefik:

tls:
certificates:
- certFile: /certs/cert.pem
keyFile: /certs/key.pem
stores:
default:
defaultCertificate:
certFile: /certs/fullchain.pem
keyFile: /certs/key.pem

Building a Chain Manually

If you got certs from a non-Let’s Encrypt CA and they gave you separate files, you can build the chain yourself:

Terminal window
cat cert.pem intermediate.pem > fullchain.pem

Order matters: leaf cert first, then intermediates. The root cert is optional (clients have it already). If you have multiple intermediates, add them in order from leaf to root.

Verify the chain is correct:

Terminal window
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt fullchain.pem

You should see fullchain.pem: OK. If you see “unable to get local issuer certificate”, something’s in the wrong order or missing.

Cert Expiry and Renewal

While you’re here: check when your cert expires:

Terminal window
echo | openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -dates

Output:

notBefore=Jan 1 00:00:00 2025 GMT
notAfter=Apr 1 00:00:00 2025 GMT

Let’s Encrypt certs expire every 90 days. If you’re using Certbot with auto-renewal, the certbot.timer systemd unit handles it. But it only replaces the files — it doesn’t reload NGINX. Add a deploy hook:

/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
systemctl reload nginx
Terminal window
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

Now every renewal automatically reloads NGINX with the new chain. No manual intervention needed.

Why This Still Trips People Up

Let’s Encrypt used to be unclear about which file was which. The names cert.pem vs fullchain.pem aren’t exactly screaming “include both these files.” And most online tutorials show the wrong approach because they were written in 2016 and nobody updated them.

Modern ACME clients (Certbot, etc.) do the right thing by default now. But if you’re renewing old configs or using an older client, you might still have cert.pem lying around.

Bottom line: always check your reverse proxy config. Use fullchain.pem. Test with openssl s_client. Set up a renewal deploy hook. Your users — and your mom — will thank you.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it may appear here.


Previous Post
Ollama Beyond the Basics: Model Management, Custom Models, and Optimization
Next Post
Nextcloud Advanced: Federation, Backups, and Making It Actually Performant

Related Posts