https is http plus TLS — same API, same req/res shape, but the bytes on the wire are encrypted. In real production we almost never expose Node’s HTTPS server directly; a reverse proxy (Caddy, Nginx, ALB) handles TLS and forwards plain HTTP to our app. But knowing the raw module matters when we build internal mTLS services, talk to a third-party API with a custom cert, or troubleshoot why our fetch says “self-signed certificate.”
In simple language: TLS is the encryption layer between TCP and HTTP. We give Node a private key + certificate, it does the handshake with clients, and our handler code sees a normal request.
A minimal HTTPS server
import https from 'node:https';
import { readFileSync } from 'node:fs';
const server = https.createServer({
key: readFileSync('./certs/server.key'),
cert: readFileSync('./certs/server.crt'),
}, (req, res) => {
res.writeHead(200);
res.end('Hello over TLS');
});
server.listen(8443);
Same (req, res) handler as plain HTTP. The only difference is the options object with key and cert.
Where the cert comes from
For local dev we generate a self-signed cert with mkcert (handles the trust store dance):
mkcert -install
mkcert localhost 127.0.0.1
# produces localhost.pem and localhost-key.pem
For production we get certs from Let’s Encrypt (via certbot or Caddy), or from a cloud-managed cert service. Don’t ship self-signed certs to production — clients will refuse the connection unless explicitly told to ignore.
The TLS handshake — what’s happening
The client validates that our cert is signed by a CA it trusts and that the hostname matches. That’s where most TLS pain comes from.
Mutual TLS (mTLS) — the client proves who it is too
In normal HTTPS, only the server presents a cert. In mutual TLS, the client also presents a cert, and the server validates it. This is how zero-trust internal services authenticate without API keys — Kubernetes service meshes, AWS IAM Roles Anywhere, Stripe’s payment terminal API.
import https from 'node:https';
import { readFileSync } from 'node:fs';
const server = https.createServer({
key: readFileSync('./certs/server.key'),
cert: readFileSync('./certs/server.crt'),
ca: readFileSync('./certs/client-ca.crt'), // CA we trust to sign client certs
requestCert: true, // ask client for a cert
rejectUnauthorized: true, // close connection if client cert is invalid
}, (req, res) => {
const cert = req.socket.getPeerCertificate();
res.end(`Hello, ${cert.subject.CN}`);
});
server.listen(8443);
The client must present a cert signed by client-ca.crt. The server then knows exactly who’s calling.
Calling an mTLS server as a client
Same idea, other direction:
import https from 'node:https';
const req = https.request({
hostname: 'internal-api.local',
port: 8443,
path: '/data',
method: 'GET',
key: readFileSync('./certs/client.key'),
cert: readFileSync('./certs/client.crt'),
ca: readFileSync('./certs/server-ca.crt'), // CA that signed the server's cert
}, (res) => {
res.pipe(process.stdout);
});
req.end();
Common pitfalls
UNABLE_TO_VERIFY_LEAF_SIGNATURE — the server’s cert chain is incomplete. The fix is on the server side: include intermediate certs in the chain, not just the leaf. We can also point Node at extra CAs:
NODE_EXTRA_CA_CERTS=/path/to/corporate-root.pem node app.js
SELF_SIGNED_CERT_IN_CHAIN in dev — we’re calling our own self-signed server. We tell our HTTP client to trust it via ca: option. Do not set NODE_TLS_REJECT_UNAUTHORIZED=0 in production — it disables all cert checking globally and is a giant security hole.
Hostname mismatch — the cert is for api.example.com but we’re connecting to 1.2.3.4. TLS verifies the hostname. Either use the domain name, or configure the cert with a Subject Alternative Name for the IP.
Cert expiry — Let’s Encrypt certs last 90 days. If we forget to renew, the entire service goes down. Use auto-renewal (Caddy does this for free) and monitor expiry.
When to terminate TLS in Node vs at a proxy
Honestly, most of the time we put a reverse proxy in front of Node — it handles TLS, our app speaks plain HTTP internally. The proxy does cert renewal, HTTP/2, compression, often better than Node would. We reach for Node’s HTTPS when:
- We need mTLS at the application layer (auth tied to cert).
- We’re building a CLI tool or background worker that talks to a TLS-protected internal service.
- We’re writing a webhook receiver for a service that requires TLS to a specific hostname we own.
The mental model
https is http plus a { key, cert } options bag. Cert + private key go on the server. Trusted CAs go on whoever’s verifying. mTLS just means both sides present a cert. When something breaks, 90% of the time it’s a hostname mismatch, missing intermediate cert, or expired cert — not Node’s fault.