Web servers
How to run a Node.js HTTPS server with a Let's Encrypt certificate
Node.js serves HTTPS on port 443 through https.createServer() with a Let's Encrypt certificate from Certbot. A port-80 listener answers ACME challenges and redirects to HTTPS, and setSecureContext() loads renewed certificates without a restart. The steps apply to Node.js 22 and later on Linux with systemd and Certbot 4.1 or later.
Prerequisites
Node owns ports 80 and 443 in this setup, and Certbot drops challenge files into a directory Node serves.
- A Linux server with systemd, sudo access and Node.js 22 or later.
- Certbot from snap:
sudo snap install --classic certbot && sudo ln -s /snap/bin/certbot /usr/local/bin/certbot. - DNS
A/AAAArecords forexample.compointing at the server, for example203.0.113.10. - Inbound TCP 80 and 443 open to all sources.
- A dedicated user for the app, for example
node, and the app in/opt/myapp.
Step 1: Write the server
server.mjs runs an HTTP listener (ACME challenges and redirects) and an HTTPS listener, and reloads certificates on SIGHUP:
// /opt/myapp/server.mjs
import http from 'node:http';
import https from 'node:https';
import { readFileSync, createReadStream } from 'node:fs';
import path from 'node:path';
const CERT_DIR = '/etc/myapp/tls';
const ACME_DIR = '/var/www/acme/.well-known/acme-challenge';
const tlsOptions = () => ({
key: readFileSync(`${CERT_DIR}/privkey.pem`),
cert: readFileSync(`${CERT_DIR}/fullchain.pem`),
minVersion: 'TLSv1.2',
});
const app = (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('hello over https\n');
};
const server = https.createServer(tlsOptions(), app);
server.listen(443);
http.createServer((req, res) => {
if (req.url.startsWith('/.well-known/acme-challenge/')) {
const file = path.join(ACME_DIR, path.basename(req.url));
const stream = createReadStream(file);
stream.on('error', () => { res.writeHead(404); res.end(); });
stream.pipe(res);
return;
}
res.writeHead(301, { Location: `https://example.com${req.url}` });
res.end();
}).listen(80);
process.on('SIGHUP', () => {
server.setSecureContext(tlsOptions());
console.log('TLS certificate reloaded');
});
The redirect uses a fixed host name rather than the Host header, so it cannot be abused as an open redirect.
Step 2: Run it under systemd without root
Grant only the capability to bind low ports, and map systemctl reload to SIGHUP:
# /etc/systemd/system/myapp.service
[Unit]
Description=My Node.js app
After=network.target
[Service]
User=node
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/node server.mjs
ExecReload=/bin/kill -HUP $MAINPID
AmbientCapabilities=CAP_NET_BIND_SERVICE
Restart=on-failure
[Install]
WantedBy=multi-user.target
The service cannot start until a certificate exists, so obtain one first.
Step 3: Obtain the certificate with webroot
Use standalone mode once for the first issuance, while port 80 is still free:
sudo mkdir -p /var/www/acme
sudo certbot certonly --standalone -d example.com -d www.example.com
Renewals cannot use standalone mode once Node owns port 80, so the last command in Step 4 switches this certificate to webroot mode.
Step 4: Install the certificate for the app and start it
A deploy hook copies the files to a directory the node user can read and sends SIGHUP:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/myapp.sh > /dev/null <<'EOF'
#!/bin/sh
set -e
install -d -o node -g node -m 700 /etc/myapp/tls
install -o node -g node -m 644 "$RENEWED_LINEAGE/fullchain.pem" /etc/myapp/tls/fullchain.pem
install -o node -g node -m 600 "$RENEWED_LINEAGE/privkey.pem" /etc/myapp/tls/privkey.pem
systemctl reload myapp || true
EOF
sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/myapp.sh
sudo RENEWED_LINEAGE=/etc/letsencrypt/live/example.com /etc/letsencrypt/renewal-hooks/deploy/myapp.sh
sudo systemctl daemon-reload && sudo systemctl enable --now myapp
sudo certbot reconfigure --cert-name example.com --webroot -w /var/www/acme
/var/www/acme must be readable by the node user.
Verify
The endpoint should serve the full Let's Encrypt chain on port 443, and HTTP should redirect to HTTPS. Check it with the SSL checker, or from any terminal:
curl ssl.sh/example.com
Locally, confirm HTTPS, the redirect and the reload path:
curl -s https://example.com
curl -sI http://example.com | head -n 3
sudo systemctl reload myapp && journalctl -u myapp -n 5 --no-pager
Automatic renewal
The Certbot snap timer runs certbot renew twice a day and follows Let's Encrypt's ARI window (Certbot 4.1 and later). Each renewal writes challenge files to /var/www/acme, which Node serves on port 80, and the deploy hook copies the new files and triggers setSecureContext() through SIGHUP. No connections are dropped.
sudo certbot renew --dry-run
Dry runs skip deploy hooks, so confirm the new expiry date after the first real renewal. The setup needs no change when Let's Encrypt's default lifetime drops to 64 days on February 10, 2027. Add the domain to ssl.sh monitoring (in development) as a safety net.
Troubleshooting
- Error: listen EACCES: permission denied 0.0.0.0:443: the service lacks
AmbientCapabilities=CAP_NET_BIND_SERVICE, or you started Node by hand as a normal user. - error:...:PEM routines::no start line: a path points at the wrong file or an empty file; check
/etc/myapp/tls. - Clients fail with UNABLE_TO_VERIFY_LEAF_SIGNATURE:
certiscert.peminstead offullchain.pem; see UNABLE_TO_VERIFY_LEAF_SIGNATURE. - ERR_SSL_PROTOCOL_ERROR: an
http.createServerlistener is bound to 443; see ERR_SSL_PROTOCOL_ERROR. - Renewal fails with a 404 on the challenge: the challenge file path or permissions are wrong; see certbot timeout during connect for the other common causes.
FAQ
Should Node.js terminate TLS itself or sit behind Nginx or Caddy?
A reverse proxy is the more common production setup: Nginx with Certbot or Caddy handles certificates, HTTP/2 and redirects, and Node listens on 127.0.0.1. Terminate TLS in Node when you have a single service and want no extra moving parts.
Does the same code work with Express, Fastify or Koa?
Yes. Express and Koa apps are request handlers, so pass them to https.createServer(options, app). Fastify accepts an https: { key, cert } option and exposes the underlying server as fastify.server, on which setSecureContext() works the same way.
Why do some clients fail with UNABLE_TO_VERIFY_LEAF_SIGNATURE?
The server is sending only the leaf certificate. Load fullchain.pem as cert. See UNABLE_TO_VERIFY_LEAF_SIGNATURE.