You know the ritual. You spin up a new server, install certbot, run the command, hope it works, set up a cron job for renewal, pray it doesn't silently fail three months later at 3am, and if it does — your users wake up to a scary browser warning and you wake up to a panicked Slack message.
And that's the good scenario. The bad scenario is when you're running Node.js behind nginx just for SSL termination. Your app handles everything — routing, middleware, WebSockets, API logic — but nginx sits in front of it for one reason: HTTPS certificates. An entire reverse proxy, with its own configuration language, its own process, its own failure modes, just to handle TLS.
Or maybe you've gone the cloud route. You pay for AWS Certificate Manager or Cloudflare's managed certificates. They work great — until you need a certificate for something that isn't behind their load balancer. An internal service, a custom protocol, a TURN server, a mail server.
What if your Node.js app could just... get its own certificates? Automatically? Without any external tools?
ACME: The Protocol Behind Let's Encrypt
Before we look at code, it helps to understand what's actually happening when you get a free SSL certificate.
Let's Encrypt (and ZeroSSL, and other free CAs) use a protocol called ACME — Automatic Certificate Management Environment (RFC 8555). It's an API. Your client talks to the CA's server, proves you control a domain, and gets a signed certificate back.
The proof of domain control usually works like this: the CA says "put this specific value in a DNS TXT record for your domain." You do it. The CA checks. If the record is there, you clearly control the domain. Certificate issued.
The protocol itself isn't complicated. But the implementation is: you need to generate keys, create a signed JWT for every request, encode a CSR in DER format with ASN.1, handle nonce rotation, poll for authorization status, and deal with edge cases like badNonce errors and rate limits.
Most developers don't want to learn ASN.1 DER encoding. They just want HTTPS.
cert-manager: HTTPS Certificates in 10 Lines
cert-manager is a zero-dependency ACME client for Node.js. It handles the entire certificate lifecycle — from key generation to renewal — with an event-driven API that hides all the protocol complexity.
npm install cert-manager
Here's a wildcard certificate for your domain:
import ssl from 'cert-manager';
var order = ssl.createOrder({
domain: 'example.com',
wildcard: true,
email: 'admin@example.com',
});
order.on('dns', (records, done) => {
// Set these DNS TXT records at your provider, then:
console.log('Set DNS records:', records);
done();
});
order.on('certificate', (cert) => {
console.log(cert.cert); // Server certificate (PEM)
console.log(cert.ca); // CA chain (PEM)
console.log(cert.key); // Private key (PEM)
console.log(cert.expiresAt); // Expiry date
});
order.start();
That's it. No manual account creation. No CSR generation. No JWS signing. No nonce management. Everything — key generation, account registration, order creation, DNS challenge setup, verification polling, challenge completion, and certificate download — happens automatically, step by step.
You subscribe to events. The library handles the protocol.
What Happens When You Call order.start()
It's worth understanding the flow, because it shows how much complexity the library absorbs:
1. Key generation. Two ECDSA P-256 keys are generated — one for the ACME account, one for the certificate. If you provide existing keys, they're reused.
2. Account registration. The library creates a signed request to the CA, registers an account with your email, and stores the account URL. Next time, it reuses the same account.
3. CAA preflight. Before even starting the order, the library checks your domain's CAA records to make sure the CA is allowed to issue for it. This catches misconfigurations early instead of failing after DNS setup.
4. Order creation. A new certificate order is created with the CA, requesting the domain (and *.domain if wildcard is enabled).
5. DNS challenge. The CA responds with DNS TXT records that need to be set. The library emits the dns event with the exact records and waits for you to call done().
6. Verification. The library polls DNS (using fallback resolvers 8.8.8.8 and 1.1.1.1) to confirm the records are visible before telling the CA to check. This prevents the common failure mode where the CA checks before DNS has propagated.
7. Challenge completion. The library submits the challenge responses and polls the CA until all authorizations are valid.
8. Finalization. A CSR is generated, signed, DER-encoded, and submitted. The CA signs it and returns the certificate.
9. Certificate event. You get cert.cert, cert.ca, cert.key, and cert.expiresAt. Done.
If anything fails along the way — a badNonce error, a timeout, a DNS propagation delay — the library retries automatically. You get an error event only when it's genuinely unrecoverable.
DNS Provider Integration: Use Whatever You Have
The dns event gives you complete control. The library doesn't assume you use a specific DNS provider — it just tells you what records to set and waits for you to call done(). This means any provider with an API works:
Cloudflare:
order.on('dns', (records, done) => {
var pending = records.length;
records.forEach((record) => {
fetch(`https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${CF_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'TXT', name: record.name, content: record.value, ttl: 120 }),
}).then(() => { if (--pending <= 0) done(); });
});
});
AWS Route 53:
order.on('dns', (records, done) => {
var changes = records.map((r) => ({
Action: 'UPSERT',
ResourceRecordSet: {
Name: r.name, Type: 'TXT', TTL: 120,
ResourceRecords: [{ Value: `"${r.value}"` }]
}
}));
route53.changeResourceRecordSets({
HostedZoneId: ZONE_ID,
ChangeBatch: { Changes: changes },
}, () => done());
});
DigitalOcean, GoDaddy, Namecheap, your own DNS server — if it has an API, it works. You could even use it with dnssec-server to manage DNS programmatically and complete the challenge without any external API calls at all.
Automatic Renewal: Set It and Forget It
Getting a certificate once is nice. Making sure it renews every 90 days without you thinking about it is what you actually need.
The manager handles multi-domain certificate management with persistent storage and automatic renewal:
import ssl from 'cert-manager';
var mgr = ssl.manager({
dir: './certs',
email: 'admin@example.com',
});
mgr.add('example.com', { wildcard: true });
mgr.add('api.example.com');
mgr.add('other.com');
mgr.on('dns', (domain, records, done) => {
setDnsRecords(domain, records).then(() => done());
});
mgr.on('certificate', (domain, cert) => {
console.log(domain, 'certificate ready!');
reloadServer(cert);
});
mgr.on('renewing', (domain, daysLeft) => {
console.log(domain, 'renewing,', daysLeft, 'days left');
});
mgr.start();
That's a production certificate manager. It stores certificates and keys to disk, tracks expiry dates, and renews 7 days before expiration. On process restart, mgr.start() reads the stored state and picks up where it left off — no need to call add() again.
The manager includes protections that come from experience with real-world certificate management: domains are processed one at a time (never parallel, to avoid rate limits), failed domains are throttled (minimum 4 hours between retries), each domain has a 10-minute timeout, and domains that have never been attempted are prioritized.
Zero Dependencies — And What That Actually Means
cert-manager uses only Node.js built-in modules. That sounds like a nice-to-have, but for a crypto library it's significant.
The ACME protocol requires several cryptographic operations: ECDSA key generation, JWS (JSON Web Signature) creation, CSR generation with ASN.1 DER encoding, and HMAC signing for External Account Binding. Most Node.js ACME libraries pull in node-forge, jose, or other dependencies for these operations.
cert-manager implements all of it from scratch using node:crypto. The ASN.1 DER encoder, the JWS signer, the CSR builder — all in a few hundred lines of JavaScript. This means:
- No supply chain risk from third-party crypto dependencies
- No version conflicts with other packages in your project
- The code is small enough to audit (the entire
src/directory is seven files)
Works With Let's Encrypt and ZeroSSL
Let's Encrypt is the default provider, but ZeroSSL works too — useful if you need more certificates than Let's Encrypt's rate limits allow, or if you want a different CA for redundancy:
ssl.createOrder({
domain: 'example.com',
email: 'admin@example.com',
provider: 'zerossl',
eab: {
kid: 'YOUR_EAB_KID',
hmacKey: 'YOUR_EAB_HMAC_KEY',
},
});
Any ACME-compatible CA will work with the same API.
Testing with Staging
Let's Encrypt has strict rate limits on production certificates. While developing your integration, use the staging environment:
ssl.createOrder({
domain: 'example.com',
email: 'admin@example.com',
staging: true, // Uses Let's Encrypt staging — no rate limits
});
The staging CA issues real certificates signed by a fake root — they'll show browser warnings, but they validate that your entire flow (DNS records, verification, issuance) works correctly before you switch to production.
Putting It All Together: HTTPS Server With Auto-Renewal
Here's what a self-managing HTTPS server looks like:
import ssl from 'cert-manager';
import https from 'node:https';
var currentCert = null;
var mgr = ssl.manager({
dir: './certs',
email: 'admin@example.com',
});
mgr.add('myapp.com', { wildcard: true });
mgr.on('dns', (domain, records, done) => {
setDnsRecords(domain, records).then(() => done());
});
mgr.on('certificate', (domain, cert) => {
currentCert = cert;
console.log('Certificate ready, expires:', cert.expiresAt);
});
mgr.start();
// Server uses SNICallback to always serve the latest certificate
var server = https.createServer({
SNICallback: (servername, cb) => {
if (currentCert) {
cb(null, require('tls').createSecureContext({
key: currentCert.key,
cert: currentCert.cert + '\n' + currentCert.ca.join('\n'),
}));
}
}
}, app);
server.listen(443);
No certbot. No cron. No nginx. The certificate renews itself, and the server picks up the new certificate automatically through SNICallback.
Getting Started
npm install cert-manager
Start with staging to validate your setup:
import ssl from 'cert-manager';
var order = ssl.createOrder({
domain: 'yourdomain.com',
email: 'you@yourdomain.com',
staging: true,
});
order.on('dns', (records, done) => {
console.log('Set these DNS records:', records);
// Set them manually or via API, then:
done();
});
order.on('certificate', (cert) => {
console.log('It works! Switch staging to false for production.');
});
order.start();
Resources:
The Bigger Picture
Certificate management shouldn't be a separate system. It shouldn't require a separate tool, a separate configuration language, a separate daemon running on your server. It's a fundamental part of running an HTTPS service — and it belongs in your application code.
When your certificates are managed by the same code that serves your app, everything simplifies. Deployment is one process. Monitoring is one log stream. Renewal is automatic. And when something goes wrong, you debug it the same way you debug everything else — in JavaScript, with the tools you already know.
Your server, your certificates, your code. No middlemen.