The short answer
Node does not read a certificate bundle the way Nginx does. You hand PEM strings to https.createServer(), and the two options people confuse do opposite jobs: cert is the chain you send to clients, and it must hold your leaf certificate followed by every intermediate, while ca is the list of roots this process trusts when it verifies somebody else — and setting it replaces Node's built-in root list instead of adding to it. Once the server is running, server.setSecureContext() swaps in a renewed certificate without interrupting existing connections, which is what makes 200-day and shorter lifetimes survivable in a long-lived process.
On this page
- Should Node terminate TLS, or something in front of it?
- What the CA sends you, and what Node wants
- Installing the certificate on an HTTPS server
- Why the intermediate goes in cert, not in ca
- Express, Fastify and HTTP/2
- Trusting a private CA without breaking everything else
- Reloading a renewed certificate without dropping connections
- Binding port 443 without root, and what that quietly breaks
- Proving it works from outside the process
- The error codes, decoded
- FAQ
Should Node terminate TLS, or something in front of it?
Terminate TLS inside Node when the Node process is the only thing listening on that hostname, or when your application code needs the client's certificate for mutual TLS. Put a reverse proxy in front when several instances serve the same name, because then one certificate and one renewal cover all of them instead of one apiece. The encryption is the same either way. What changes is how many places a renewal has to reach.
This is worth settling first, because the answer decides how much of the rest of this guide you need. A single container serving an internal tool has no reason to grow a proxy. Six instances behind an application load balancer have every reason not to hold six copies of the same private key, each with its own renewal timer that can fail independently and will, one night, fail on exactly one of them.
The mutual TLS case is the one that overrides the count. Mutual TLS requires the server to inspect the certificate the client presents, and a proxy that terminates TLS has already consumed that information by the time your handler runs. You can forward it in a header, but then you are trusting a header, and every path into that backend has to be closed off before that is honest. Terminating in Node keeps the peer certificate where the authorisation decision is made.
One thing that is not a good reason either way: performance. TLS handshake cost on modern hardware is not what limits a Node service, and a proxy in front of it is not there to make encryption cheaper. If you want the fuller version of that argument, including when re-encrypting between the proxy and your backend is worth the trouble, we cover it in TLS termination and SSL offloading.
What the CA sends you, and what Node wants
You need three things: the private key you generated, your issued certificate, and the intermediate certificates that connect it to a public root. Node reads all of them as PEM text, the format that starts with a -----BEGIN CERTIFICATE----- line. If your files are binary, or arrived as a single .pfx, they need converting or a different option — Node will not guess.
The private key never leaves the machine that generated it, so the sequence starts with a certificate signing request. Generate it wherever you prefer; our browser-based CSR generator keeps the key on your own machine and hands you both halves. If you do not have a certificate yet, the DV, OV and EV certificates we issue all arrive as the same three PEM parts, and nothing below changes based on which validation level you chose.
What varies between certificate authorities is the packaging. Some send four separate files. Some send a fullchain.pem with the leaf and intermediates already concatenated, which is what ACME clients such as Certbot write. Some send a .zip containing a bundle whose filename gives no hint about what is inside it. Before writing any code, find out how many certificates you actually have:
Count what you were sent
# How many certificates are in each file?
grep -c 'BEGIN CERTIFICATE' fullchain.pem # 2 or 3 is normal
grep -c 'BEGIN CERTIFICATE' yourdomain.crt # usually 1
# What is each one, and in what order?
openssl crl2pkcs7 -nocrl -certfile fullchain.pem \
| openssl pkcs7 -print_certs -noout
# Does the private key belong to this certificate?
# The two hashes must match.
openssl pkey -in privkey.pem -pubout -outform der | openssl sha256
openssl x509 -in yourdomain.crt -pubkey -noout -outform der | openssl sha256That last pair is worth running every time. A key and certificate that do not match produce a startup error whose text blames the key format, and people spend an afternoon converting a file that was fine, when the real story is that the certificate was issued against a different CSR. The formats themselves, and the conversions between them, are covered in PEM, DER, PFX and the rest.
Installing the certificate on an HTTPS server
Read the key and the full chain from disk and pass them to https.createServer() as key and cert. That is the whole installation. There is no configuration file, no reload command and no separate certificate store: the options object you build at startup is the configuration, and the process holds the parsed result in memory until you replace it.
The minimum that is actually correct
import { createServer } from 'node:https';
import { readFileSync } from 'node:fs';
const options = {
key: readFileSync('/etc/ssl/private/privkey.pem'),
// Leaf FIRST, then each intermediate, in order. No root.
cert: readFileSync('/etc/ssl/certs/fullchain.pem'),
minVersion: 'TLSv1.2',
};
createServer(options, (req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('ok\n');
}).listen(8443, () => {
console.log('listening on 8443');
});Two details in that snippet do real work. readFileSync is deliberate: reading the certificate asynchronously at startup buys nothing and gives you a server that can begin listening before it has a key. And fullchain.pem rather than the bare certificate is the difference between a server that works everywhere and one that works on your laptop, which is the next section.
If your certificate arrived as a PKCS#12 file, swap key and cert for pfx and passphrase. The container already holds the chain, so the ordering problem cannot arise. The cost is that the passphrase now has to reach the process from somewhere, and whatever supplies it becomes part of your renewal procedure.
Why the intermediate goes in cert, not in ca
The intermediate belongs in cert, appended below your own certificate. The Node documentation states it plainly: each cert chain should consist of the certificate for the private key, followed by the intermediate certificates in order, and not including the root — and that if the intermediates are not provided, the peer will not be able to validate the certificate and the handshake will fail. The ca option plays no part in what a server sends.
The reason this mistake survives testing is that it does not fail consistently. A desktop browser that has met your certificate authority's intermediate on some other site caches it, and can quietly complete the chain your server failed to send. So Chrome shows a padlock, the developer marks the ticket done, and the failure reports arrive later from an Android app, a curl in someone's CI job and a webhook that a payment provider gave up retrying.
Assembling the chain yourself, if the CA sent parts
# Order matters: your certificate first, then up the chain.
cat yourdomain.crt intermediate.crt > fullchain.pem
# Confirm what the running server actually sends.
# Count the certificates in the reply, not in your files.
openssl s_client -connect example.com:443 -servername example.com \
-showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERTIFICATE'Or keep the order explicit in code
// cert accepts an array, which documents the order for the
// next person to read this file.
const options = {
key: readFileSync('privkey.pem'),
cert: [
readFileSync('yourdomain.crt'), // leaf
readFileSync('intermediate.crt'), // issuer of the leaf
],
};That final count is the only answer that settles the question. A reply with one certificate means the chain is incomplete however good your files look on disk. Two or three means the intermediates are going out. Note the -servername flag: without it, openssl sends no SNI, and a server that selects certificates by hostname will hand you a different one than a browser would get.
The failure mode is not specific to Node, and the wider version of it — including why the root should never be in the file — is in our guide to the certificate chain.
Express, Fastify and HTTP/2
Express does not terminate TLS. An Express app is a request handler, so you pass it to https.createServer() in place of the inline function and everything above applies unchanged. Fastify accepts the same options object under its https key. HTTP/2 needs http2.createSecureServer() and ALPN, which Node negotiates for you.
The same certificate, three servers
// Express — the app is just the request listener
import express from 'express';
import { createServer } from 'node:https';
const app = express();
app.get('/', (req, res) => res.send('ok'));
createServer(tlsOptions, app).listen(8443);
// Fastify — options go under the https key
import Fastify from 'fastify';
const fastify = Fastify({ https: tlsOptions });
await fastify.listen({ port: 8443 });
// HTTP/2 — allowHTTP1 keeps older clients working
import { createSecureServer } from 'node:http2';
createSecureServer({ ...tlsOptions, allowHTTP1: true }, handler)
.listen(8443);Serving several hostnames from one process is where Node gets more interesting than a config file. server.addContext(hostname, context) registers a certificate against a name or wildcard, and the server picks by SNI at handshake time. For a set of names known at startup, that is a loop over your certificates. For a platform issuing certificates per customer domain, SNICallback lets you resolve the context at connection time from whatever store you keep them in, which is how custom-domain hosting works without a restart for every new tenant.
On Node v24.19 and later there is also certificateCompression, which enables TLS certificate compression from RFC 8879 for TLS 1.3 connections using zlib, brotli or zstd. It shrinks the handshake rather than the traffic, so it matters most where chains are long and connections are short-lived. It is off by default, and turning it on is not a fix for anything that is currently broken.
Trusting a private CA without breaking everything else
Use NODE_EXTRA_CA_CERTS, not the ca option. The environment variable extends Node's well-known roots with the certificates in the file you point at. The ca option, by contrast, is documented to replace the default list completely rather than concatenate with it, so a process that adds your internal root this way stops trusting every public certificate authority at the same moment.
That is what makes this the most expensive mistake on the page. The internal call you were fixing starts working, so the change looks correct. The damage lands somewhere else entirely: the Stripe client, the S3 upload, the OAuth token refresh — all unrelated code, none of it touched, all of it now failing to verify certificates that were fine an hour ago. The error text points at the remote server, which is the one thing that has not changed.
Adding trust instead of replacing it
# Additive, and the usual right answer. Read at launch only.
NODE_EXTRA_CA_CERTS=/etc/ssl/certs/internal-root.pem node server.js
# Node v22.19+ / v24.6+: use the OS trust store you already manage
node --use-system-ca server.js
# Check what the process really trusts, from inside it
node -e "const t=require('tls');
console.log('bundled', t.getCACertificates('bundled').length);
console.log('extra ', t.getCACertificates('extra').length);
console.log('default', t.getCACertificates('default').length)"
# If you must scope trust to ONE client call, keep it local
# to that call rather than setting it process-wide:
const agent = new https.Agent({
ca: [readFileSync('internal-root.pem')],
});
await fetch('https://internal.example.com', { agent });tls.getCACertificates() arrived in Node v22.15 and v23.10, and it is the fastest way to end an argument about whether a root is loaded. If extra returns an empty array, your environment variable did not take effect, and the next section covers the least obvious reason why.
One thing not to do, whatever a search result suggests: setting rejectUnauthorized: false, or the NODE_TLS_REJECT_UNAUTHORIZED=0 variable, turns certificate verification off rather than fixing it. The connection is still encrypted, and it will now accept a certificate from anyone at all, which removes the part of TLS that tells you who you are talking to. It is a debugging step, not a configuration.
Reloading a renewed certificate without dropping connections
Call server.setSecureContext() with the new key and certificate. The method has been in Node since v11 and the documentation is explicit that it replaces the secure context of a running server and that existing connections are not interrupted. In-flight requests finish against the old certificate; every handshake after the call gets the new one. No restart, no dropped sockets, no maintenance window.
This is the part of Node TLS that most guides get wrong, and the cost of getting it wrong is rising. Since 15 March 2026 a public certificate can be valid for at most 200 days, and under the CA/Browser Forum schedule adopted in ballot SC-081v3 that ceiling drops to 100 days in March 2027 and 47 days in March 2029. A process that only learns about new certificates by being restarted turns each of those renewals into a deployment.
A reload that is safe to run unattended
import { createServer } from 'node:https';
import { readFileSync } from 'node:fs';
import { watch } from 'node:fs/promises';
const KEY = '/etc/ssl/private/privkey.pem';
const CHAIN = '/etc/ssl/certs/fullchain.pem';
function loadContext() {
// Read both, then use them. A half-written pair must never
// reach setSecureContext, so let a throw here abort the swap.
const key = readFileSync(KEY);
const cert = readFileSync(CHAIN);
if (!cert.includes('BEGIN CERTIFICATE')) {
throw new Error('chain file is not PEM yet');
}
return { key, cert };
}
const server = createServer(loadContext(), handler).listen(8443);
// Debounced: ACME clients write several files in quick succession.
let pending;
function reload() {
clearTimeout(pending);
pending = setTimeout(() => {
try {
server.setSecureContext(loadContext());
console.log('certificate reloaded', new Date().toISOString());
} catch (err) {
// Keep serving the old certificate. A failed swap must not
// take the listener down.
console.error('reload skipped:', err.message);
}
}, 2000);
}
for await (const _ of watch('/etc/ssl/certs')) reload();
// Or, if you would rather not watch the filesystem at all:
process.on('SIGHUP', reload);Three things in there are the difference between a reload that helps and one that causes the outage it was meant to prevent. The debounce exists because a renewal writes several files over a second or two, and a watcher without one will fire while the chain file is half written. The validation exists because readFileSync will happily return a truncated file. And the try block exists because the correct response to a bad new certificate is to carry on serving the old one, which is still valid, rather than to fail loudly at three in the morning.
The SIGHUP variant is worth considering even if you keep the watcher. It gives your ACME client a deploy hook to call — kill -HUP against the pid — which turns the reload into something that happens after renewal succeeds rather than something that races it. Certbot's --deploy-hook and the equivalent in other clients exist for exactly this, and the broader pattern is in our Certbot and ACME production guide.
Test the swap on the day you write it. Start the server, replace the files with a second certificate, confirm the log line, then open a fresh connection and check which certificate you get. Discovering six months later that the watcher was never firing — because the ACME client writes to a directory you are not watching, or replaces a symlink rather than the file behind it — is how this fails in practice.
Binding port 443 without root, and what that quietly breaks
A non-root process cannot bind ports below 1024 on Linux, so listen(443) fails with EACCES. The usual fixes are to grant the Node binary the CAP_NET_BIND_SERVICE capability, to set AmbientCapabilities in a systemd unit, or to listen on a high port and let a proxy or a firewall rule forward 443 to it. All three work. One of them has a side effect that is easy to miss.
Node ignores NODE_EXTRA_CA_CERTS entirely when the process runs setuid root or has Linux file capabilities set. So if you granted the capability directly on the binary with setcap and you also rely on the variable to trust an internal certificate authority, the variable silently does nothing. Trust looks configured, the file exists, the path is right, and tls.getCACertificates('extra') returns an empty array.
Three ways to reach 443, and what each costs
# 1. Capability on the binary — simple, but disables
# NODE_EXTRA_CA_CERTS for every process using that binary.
sudo setcap 'cap_net_bind_service=+ep' "$(readlink -f "$(which node)")"
# 2. systemd ambient capability — same result, scoped to this
# service, and survives a Node upgrade replacing the binary.
# [Service]
# AmbientCapabilities=CAP_NET_BIND_SERVICE
# Environment=NODE_EXTRA_CA_CERTS=/etc/ssl/certs/internal-root.pem
# 3. High port plus a redirect — no capability anywhere.
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=443 # or:
sudo iptables -t nat -A PREROUTING -p tcp --dport 443 \
-j REDIRECT --to-port 8443
# Whichever you pick, confirm the trust store afterwards:
node -e "console.log(require('tls').getCACertificates('extra').length)"The systemd route is the one we would reach for on a server that needs both. Ambient capabilities are granted to the service rather than stamped onto the binary, so a Node upgrade does not silently remove them, and other Node processes on the same host keep their normal trust behaviour. If the only thing you need is port 443 and your trust store is untouched, the setcap one-liner is fine and you can forget this section.
Proving it works from outside the process
A browser is not a test. Check the chain from a client with no cache and no help: openssl s_client from another machine, or a checker that connects fresh. You are looking for three things — how many certificates come back, whether the names match, and whether a client that has never seen your certificate authority can build a path to a root it holds.
The checks worth keeping in your notes
# Chain length and verify result in one go.
openssl s_client -connect example.com:443 -servername example.com \
</dev/null 2>/dev/null | grep -E 'Verify return code|^ *[0-9]+ s:'
# Names on the certificate the server actually served.
openssl s_client -connect example.com:443 -servername example.com \
</dev/null 2>/dev/null | openssl x509 -noout -subject -dates -ext subjectAltName
# A second Node process is the most honest client here: it has
# the same trust store your other services will use.
node -e "require('https').get('https://example.com',
r => console.log('ok', r.socket.authorized),
).on('error', e => console.error('FAIL', e.code))"Verify return code: 0 (ok) from a machine that has never spoken to your server is the result that means the install is done. Anything else is a chain problem, whatever a browser shows. If the host is reachable from the internet, our SSL checker reports the same thing without you needing a second machine, and names the missing intermediate when there is one.
The error codes, decoded
Node's TLS errors name the symptom rather than the cause, and several of the most common ones are reported by the client while the fault sits on the server. The table below maps each code to the thing that is actually wrong, which is usually not the machine printing the error.
| Error | What it usually means |
|---|---|
UNABLE_TO_VERIFY_LEAF_SIGNATURE | The client could not build a path to a root it trusts. Nine times in ten the server is sending its leaf without the intermediate. |
SELF_SIGNED_CERT_IN_CHAIN | A root the client does not hold appeared in the chain — an internal CA, or a TLS-inspecting proxy on the network between you. |
DEPTH_ZERO_SELF_SIGNED_CERT | The certificate is self-signed. Expected in local development, never correct in production. |
ERR_TLS_CERT_ALTNAME_INVALID | The hostname is not in the certificate's subject alternative names. Check what the server sends for that SNI value, not what you meant to install. |
CERT_HAS_EXPIRED | A renewal landed on disk but never reached the running process. This is the failure the reload section exists to prevent. |
ERR_OSSL_PEM_NO_START_LINE | The file is not PEM. Usually DER or PKCS#12 given to cert, or a path that resolved to something empty. |
EACCES on listen | Binding a privileged port as a non-root user, or a private key the service account cannot read. Check both before reaching for sudo. |
The one to be careful with is UNABLE_TO_VERIFY_LEAF_SIGNATURE appearing in a service that previously worked. If nothing changed on the remote end, look for a ca option someone added elsewhere in the same process. Replacing the trust store in one module breaks every outbound HTTPS call in the runtime, and the stack trace will point at whichever request happened to run next. Whether a given failure is a trust store problem or a chain problem is worth being able to tell apart quickly, and our guide to trust stores sets out the distinction.