Web 服务器

Node.js 用 Let's Encrypt 证书搭建 HTTPS 服务器

作者: · 最后核实

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 验证。
  • 为应用准备专用用户,例如 node,代码放在 /opt/myapp。

第一步:编写服务

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

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

第二步:用 systemd 以非 root 用户运行

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

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

第三步:首次申请证书

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

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

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

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

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

验证

配置正确时,443 端口返回 Let's Encrypt 证书和完整证书链,HTTP 请求 301 跳转到 HTTPS,systemctl reload 后进程不重启就换上新证书。用 SSL 证书检查 从外部检查,或在终端运行:

curl ssl.sh/example.com

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

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(),不会断开连接。

sudo certbot renew --dry-run

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

常见问题排查

  • 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。
  • ERR_SSL_PROTOCOL_ERROR:443 上跑的是 http.createServer,见 ERR_SSL_PROTOCOL_ERROR。
  • 续期时验证返回 404:验证文件路径或权限不对;其他常见原因见 certbot 连接超时。

常见问题

Node.js 自己做 HTTPS 好,还是放在 Nginx 后面?

生产环境更常见的是反向代理:Nginx + Certbot 或 Caddy 负责证书、HTTP/2 和跳转,Node 只监听 127.0.0.1。只有单个服务、不想多一层组件时才让 Node 直接处理 TLS。

Express、Koa、Fastify 也能这样写吗?

能。Express 和 Koa 的 app 本身就是请求处理函数,直接传给 https.createServer(options, app)。Fastify 通过 https: { key, cert } 选项开启 HTTPS,底层 server 是 fastify.server,同样可以调用 setSecureContext()。

用阿里云、腾讯云下载的证书可以吗?

可以。下载 Nginx 格式,.pem 或 .crt 文件(已包含中间证书)作为 cert,.key 文件作为 key。注意免费证书同样需要定期更换,手动下载上传的证书要自己按时续期。

资料来源

  1. Node.js: HTTPS
  2. Node.js: TLS (SSL)
  3. Certbot User Guide