# Node.js HTTPS server with Let's Encrypt: setup and hot reload

> Run a Node.js HTTPS server with Let's Encrypt: https.createServer options, HTTP redirect, ACME webroot, port 443 without root, reload on renewal.

Source: https://ssl.sh/guides/nodejs-https-server/
Last updated: 2026-11-10

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`/`AAAA` records for `example.com` pointing at the server, for example `203.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:

```javascript
// /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:

```ini
# /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:

```bash
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:

```bash
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](https://ssl.sh/tools/ssl-checker/), or from any terminal:

```bash
curl ssl.sh/example.com
```

Locally, confirm HTTPS, the redirect and the reload path:

```bash
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.

```bash
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](https://ssl.sh/monitor/) (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**: `cert` is `cert.pem` instead of `fullchain.pem`; see [UNABLE_TO_VERIFY_LEAF_SIGNATURE](https://ssl.sh/errors/node-unable-to-verify-leaf-signature/).
- **ERR_SSL_PROTOCOL_ERROR**: an `http.createServer` listener is bound to 443; see [ERR_SSL_PROTOCOL_ERROR](https://ssl.sh/errors/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](https://ssl.sh/errors/certbot-timeout-during-connect/) for the other common causes.
