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。注意免费证书同样需要定期更换,手动下载上传的证书要自己按时续期。