Serve local HTTPS
Serve a complete chain
Configure a server to send its leaf and intermediate certificates in the correct order.
So far our chain has been short: leaf, root, done. Real deployments almost always have an intermediate CA in between. That changes what the server must send. A client may trust your root but has never seen your intermediate. So the server has to send the leaf followed by every intermediate, and the client builds the path from there up to a root it holds.
To practice this, our lab needs an intermediate. Let’s create one and let the root sign it as a CA:
openssl genpkey -algorithm RSA -out lab-intermediate.key -pkeyopt rsa_keygen_bits:3072
openssl req -new -key lab-intermediate.key -out lab-intermediate.csr -subj '/CN=Practical TLS Lab Intermediate CA'
printf 'basicConstraints = critical, CA:TRUE, pathlen:0\nkeyUsage = critical, keyCertSign, cRLSign\n' > intermediate-ext.cnf
openssl x509 -req -in lab-intermediate.csr -CA lab-ca.crt -CAkey lab-ca.key -CAcreateserial -out lab-intermediate.crt -days 30 -sha256 -extfile intermediate-ext.cnf
The extension file is what makes this a CA certificate. CA:TRUE allows it to sign, and pathlen:0 says it can only sign leaves, not further CAs.
Now reissue the server certificate from the intermediate instead of the root. The CSR from earlier still works:
openssl x509 -req -in app.csr -CA lab-intermediate.crt -CAkey lab-intermediate.key -CAcreateserial -out app.crt -days 14 -sha256 -copy_extensions copy
app.crt now says issuer=CN=Practical TLS Lab Intermediate CA. Same key, same names, one more hop to the root.
The standard fix for the server is a full-chain file: the leaf first, then each intermediate. PEM files stack, so this is one command:
cat app.crt lab-intermediate.crt > app-fullchain.crt
Order matters. The leaf comes first because TLS requires the server’s own certificate at the start. Each following certificate signs the one before it. This is the layout tools like certbot give you as fullchain.pem.
Point the server at the full chain by loading it as the cert value:
cert: fs.readFileSync('app-fullchain.crt'),
Node sends every certificate in the file, in file order. The key option doesn’t change: the private key belongs to the leaf only. Intermediates are public, so no key material for them goes anywhere near the server.
Restart the server and check what it presents now:
openssl s_client -connect 127.0.0.1:8443 -servername app.lab.test -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERTIFICATE'
# 2
Two PEM blocks means both the leaf and the intermediate went out. Drop the grep and read the s: and i: lines to confirm the order: certificate 0 is app.lab.test, certificate 1 is the intermediate. Add -CAfile lab-ca.crt and you get Verify return code: 0 (ok).
The classic mistake here is subtle because it half-works. A server sending only the leaf passes in browsers that cached the intermediate from another site. Then it fails in curl, in CI, or in a mobile app with unable to get local issuer certificate. If a TLS error shows up on some clients only, suspect the chain first.
Two things never belong in a full-chain file. Not the private key, because the file gets sent to every client. And not the root: clients already have it, they ignore a root you send, and you waste bytes on every handshake.
Lesson completed