# Node.js 配置 HTTPS 服务器：Let's Encrypt 证书与热更新

> 用 Let's Encrypt 证书运行 Node.js HTTPS 服务：https.createServer 参数、HTTP 跳转、ACME webroot、非 root 监听 443、续期后不重启热加载证书。

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

Node.js 用 `https.createServer()` 加载 Let's Encrypt 的 `fullchain.pem` 和私钥，就能在 443 端口提供 HTTPS。同一进程在 80 端口应答 ACME 验证并跳转 HTTP，续期后收到 SIGHUP 时调用 `setSecureContext()` 换上新证书，不用重启。适用于 Linux + systemd 上的 Node.js 22 及以上，Certbot 4.1 及以上。

## 前提条件

Node 直接占用 80 和 443 端口，Certbot 把验证文件写到 Node 对外提供的目录里。

- 带 systemd 的 Linux 服务器，有 sudo 权限，已安装 Node.js 22 及以上。
- 已用 snap 安装 Certbot：`sudo snap install --classic certbot && sudo ln -s /snap/bin/certbot /usr/local/bin/certbot`。
- `example.com` 的 A/AAAA 记录指向服务器，例如 `203.0.113.10`；服务器在中国大陆的需已备案。
- 防火墙和安全组放行 TCP 80、443。80 端口不通时改用 [acme.sh DNS 验证](https://ssl.sh/zh/guides/acme-sh-dnspod-aliyun-dns/)。
- 为应用准备专用用户，例如 `node`，代码放在 `/opt/myapp`。

## 第一步：编写服务

`server.mjs` 同时运行 HTTP（处理验证和跳转）和 HTTPS 两个监听，收到 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');
});
```

跳转地址用固定域名而不是请求里的 `Host` 头，避免被利用成开放跳转。

## 第二步：用 systemd 以非 root 用户运行

只授予绑定低端口的权限，并把 `systemctl reload` 映射成 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
```

没有证书时服务起不来，所以先申请证书。

## 第三步：首次申请证书

首次签发时 80 端口还空着，用一次 standalone 模式：

```bash
sudo mkdir -p /var/www/acme
sudo certbot certonly --standalone -d example.com -d www.example.com
```

Node 占用 80 端口后续期不能再用 standalone，第四步最后一条命令会把这张证书改成 webroot 方式。

## 第四步：安装证书并启动服务

部署钩子把证书复制到 `node` 用户可读的目录并发送 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` 要让 `node` 用户能读。

## 验证

配置正确时，443 端口返回 Let's Encrypt 证书和完整证书链，HTTP 请求 301 跳转到 HTTPS，`systemctl reload` 后进程不重启就换上新证书。用 [SSL 证书检查](https://ssl.sh/zh/tools/ssl-checker/) 从外部检查，或在终端运行：

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

本地确认 HTTPS、跳转和热加载：

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

## 自动续期

snap 版 Certbot 的定时器每天运行两次 `certbot renew`，4.1 及以上按 ARI 建议时间续期。续期时验证文件写入 `/var/www/acme`，由 Node 的 80 端口提供访问；部署钩子复制新证书后通过 SIGHUP 调用 `setSecureContext()`，不会断开连接。

```bash
sudo certbot renew --dry-run
```

演练不执行部署钩子，第一次实际续期后要确认线上到期时间。2027 年 2 月 10 日 Let's Encrypt 默认证书改为 64 天，这套配置不用改。建议把域名加到 [ssl.sh 监控](https://ssl.sh/zh/monitor/)（开发中）。

## 常见问题排查

- **Error: listen EACCES: permission denied 0.0.0.0:443**：服务缺少 `AmbientCapabilities=CAP_NET_BIND_SERVICE`，或者你用普通用户手动启动了 Node。
- **error:...:PEM routines::no start line**：路径指向了错误或空的文件，检查 `/etc/myapp/tls`。
- **客户端报 UNABLE_TO_VERIFY_LEAF_SIGNATURE**：`cert` 用了 `cert.pem` 而不是 `fullchain.pem`，见 [UNABLE_TO_VERIFY_LEAF_SIGNATURE](https://ssl.sh/zh/errors/node-unable-to-verify-leaf-signature/)。
- **ERR_SSL_PROTOCOL_ERROR**：443 上跑的是 `http.createServer`，见 [ERR_SSL_PROTOCOL_ERROR](https://ssl.sh/zh/errors/err-ssl-protocol-error/)。
- **续期时验证返回 404**：验证文件路径或权限不对；其他常见原因见 [certbot 连接超时](https://ssl.sh/zh/errors/certbot-timeout-during-connect/)。
