How to build a correct fullchain.pem.

One file, leaf first, then each intermediate in order, no root. That is the whole specification, and it is still the most commonly broken part of a TLS deployment. Here is how to build it from whatever your CA gave you, how to check it before you reload, and where each server and load balancer wants it.

Written by · Published 2026-09-23 · 6 min read

What goes in it

A TLS server sends a list of certificates in the handshake. RFC 5246 says the first must be the server's own certificate and each following one must directly certify the one before it. The list stops before the root, because the client has the root already and will not trust a copy you send anyway. So a correct file is:

-----BEGIN CERTIFICATE-----
(your leaf: CN or SAN is your hostname)
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
(the intermediate that signed your leaf)
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
(the intermediate that signed that one, if there is one)
-----END CERTIFICATE-----

Most public chains today have one intermediate; some have two. The root is not in the file. The private key is not in the file either, except for HAProxy, which wants it appended, and a few appliances that want a PKCS#12 bundle instead.

If you want to skip the assembly: the SSL certificate chain checker connects to your server, works out the correct path to a trusted root, and prints exactly this file at the bottom of the result. Copy it out, drop it in place of what you have, reload. The rest of this guide is for when you would rather understand what you are pasting.

Starting from what your CA gave you

Certbot and Let's Encrypt

Already done for you. /etc/letsencrypt/live/example.com/ contains cert.pem (leaf only, do not use), chain.pem (intermediates only), fullchain.pem (leaf plus intermediates, use this) and privkey.pem. The mistake is pointing the server at cert.pem. Change the path; nothing else.

acme.sh, lego, Caddy, Traefik, cert-manager

All write a full chain by default (acme.sh calls it fullchain.cer; cert-manager puts it in tls.crt). If your chain is incomplete with one of these, something downstream is splitting it: a script that extracts the first certificate, a Kubernetes ingress annotation, or a copy step that used the wrong file.

A commercial CA (Sectigo, DigiCert, GlobalSign, GoDaddy and so on)

You received a zip or an email with several files. Names vary, but there will be your certificate (example_com.crt, certificate.crt, ServerCertificate.cer) and one or more CA certificates (ca-bundle.crt, intermediate.crt, SectigoRSADomainValidationSecureServerCA.crt, DigiCertCA.crt). Sometimes the root is in there too (AAACertificateServices.crt, USERTrustRSAAAACA.crt). Concatenate leaf first, then the intermediates, and leave the root out:

cat example_com.crt intermediate.crt > fullchain.pem
# or, if they sent a bundle that already has the intermediates in order:
cat example_com.crt ca-bundle.crt > fullchain.pem

If the files are DER (binary, often .cer or .der), convert first: openssl x509 -inform der -in file.cer -out file.pem. If the bundle contains the root as well, it will still work, but you are sending bytes the client throws away; the checker labels it "root sent" so you can trim it.

The CA's download page was no help

Your leaf tells you where to get its issuer. The AIA extension carries a URL, and the intermediate is a DER file at that URL:

openssl x509 -in example_com.crt -noout -text | grep -A1 'Authority Information Access'
#   CA Issuers - URI:http://r11.i.lencr.org/
curl -s http://r11.i.lencr.org/ | openssl x509 -inform der -out intermediate.pem
cat example_com.crt intermediate.pem > fullchain.pem

Repeat on the intermediate if its own issuer is not a root. This is exactly what Chrome does behind the scenes and exactly what the chain checker does for the "missing" entries it shows.

Checking the file before you reload

Two checks catch every mistake I have seen. First, that the certificates are in order and each was issued by the next:

openssl crl2pkcs7 -nocrl -certfile fullchain.pem | openssl pkcs7 -print_certs -noout
# subject=CN = example.com
# issuer=C = US, O = Let's Encrypt, CN = R11
#
# subject=C = US, O = Let's Encrypt, CN = R11
# issuer=C = US, O = Internet Security Research Group, CN = ISRG Root X1

Each issuer line should match the subject line below it. The last issuer should be a root you can find in the system store (grep -c "ISRG Root X1" /etc/ssl/certs/ca-certificates.crt). Second, that the leaf matches the private key you are about to pair it with, which is the check that saves you from the wrong-file-with-the-right-name problem:

openssl x509 -in fullchain.pem -noout -pubkey | openssl sha256
openssl pkey -in privkey.pem -pubout | openssl sha256
# the two hashes must be identical

Where each server wants it

ServerConfiguration
nginxssl_certificate /etc/ssl/site/fullchain.pem; and ssl_certificate_key /etc/ssl/site/privkey.pem;. nginx reads every certificate in the file and sends them in order. There is no separate chain directive; if you see one in an old config it is from a different server.
Apache 2.4.8 and laterSSLCertificateFile /etc/ssl/site/fullchain.pem. The old SSLCertificateChainFile still works but is deprecated; you do not need both, and having both with different intermediates is a classic cause of the wrong one being served.
Apache before 2.4.8SSLCertificateFile takes the leaf only; put the intermediates in SSLCertificateChainFile. Or upgrade; 2.4.8 is from 2014.
HAProxyWants everything in one file including the key: cat fullchain.pem privkey.pem > site.pem, then bind :443 ssl crt /etc/haproxy/certs/site.pem. Pointing crt at a directory loads every .pem in it and picks by SNI.
Caddy, TraefikIf you give them a file, give them the full chain. If they obtain certificates themselves, they build it correctly.
Node.js httpscert: fs.readFileSync('fullchain.pem'). Node reads the whole file and sends every certificate. The older ca: option is for verifying clients, not for your own chain, and putting the intermediate there does nothing for visitors.
Gotls.LoadX509KeyPair("fullchain.pem", "privkey.pem") loads the whole chain.
Java (Tomcat, Spring)Import the full chain into the keystore under the same alias as the key: keytool -importcert -alias tomcat -file fullchain.pem -keystore keystore.p12. Importing only the leaf against a key that already exists is the usual mistake and gives a one-certificate chain.
IIS / WindowsImport as PFX (openssl pkcs12 -export -in fullchain.pem -inkey privkey.pem -out site.pfx). Schannel builds the chain from the machine's intermediate store, so also install the intermediate into "Intermediate Certification Authorities" on every node.

Load balancers and CDNs

When you upload your own certificate to a cloud load balancer, there is a separate field for the chain and it is the thing people leave blank.

After you reload

Test with something that is not a browser. curl -vI https://example.com from a machine you did not just configure, or the chain checker, which should now show the chain complete and every client in the verdict table passing. If you have more than one edge, test each by address, because the deployment that reaches three servers out of four is the one that turns into an intermittent incident.

Then put a strict client in the deployment pipeline so the next renewal cannot regress it silently: a curl step, or the domain health checker on a schedule, either of which fails on an incomplete chain where a browser would not.

All guides · All tools