我的编程技术分享 返回文章列表

腾讯云轻量服务器部署 Node 服务:Nginx + Let's Encrypt 全流程

运维NginxHTTPS 2026-10-18

一台 4 核 4G 的轻量应用服务器,空白 Ubuntu 22.04 镜像,目标是把一个 Node 服务加上 HTTPS 跑起来, 给小程序做接口。看起来是半小时的活,实际卡了整整一晚。这篇按踩坑顺序记录。

一、国内装 Docker,别用官方脚本

几乎所有教程都是这一行:

curl -fsSL https://get.docker.com | sh

在国内服务器上,这条命令有相当概率卡在下载阶段然后超时。更麻烦的是它的失败方式是静默的: 如果脚本里写的是

if ! command -v docker >/dev/null 2>&1; then
  curl -fsSL https://get.docker.com -o /tmp/get-docker.sh && sh /tmp/get-docker.sh >/dev/null
fi

curl 超时返回非 0,&& 后面的 sh 不执行, 整个 if 块的退出码却是 0——set -e 不会让它停下来。 脚本继续往下跑,装 Nginx、签证书、写配置,全部成功,最后在「启动服务」那一步失败。 回头看日志,前面一片绿,很有迷惑性。

稳妥的做法是走发行版自己的仓库:

sudo apt-get update
sudo apt-get install -y docker.io docker-compose-v2
sudo systemctl enable --now docker
docker --version && docker compose version

Ubuntu 22.04 仓库里的版本不算新,但对跑一个 Node 服务完全够用。 Compose 插件如果提示找不到 docker-compose-v2,退到 docker-compose 也能用。

写部署脚本时,凡是「下载外部资源后执行」的步骤,都要显式检查退出码并给出可执行的补救命令。 set -e 挡不住管道和条件语句里的失败。

二、拉基础镜像只有 50 KB/s

Docker 装好之后,docker compose up -d --build 卡在 pulling node:20-slim。 盯了两分钟,进度条几乎不动。实测下行速度大约 50 KB/s,一个 70MB 的镜像层要拉半小时。

配镜像加速:

sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{
  "registry-mirrors": ["https://docker.1ms.run", "https://docker.m.daocloud.io"]
}
EOF
sudo systemctl restart docker
sudo docker info | grep -A2 "Registry Mirrors"

要确认 docker info 里真的出现了这两行。写在文件里但服务没重启的话,配置是不生效的, 而 up 的表现和没配一样。

第二层坑:npm install 也慢

镜像拉下来之后,构建卡在 npm ci。官方源在国内同样慢,所以在 Dockerfile 里提前指定:

WORKDIR /app
COPY package.json package-lock.json ./
ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com
RUN npm ci --omit=dev || npm install --omit=dev

那个 || 是有意加的:某些镜像源下 npm ci 会因为 lockfile 与 registry 的元数据不一致而失败,退回 npm install 通常能过。构建镜像不需要 lockfile 那么严格的保证。

三、证书:不需要买

Let's Encrypt 免费,90 天有效期,可以自动续期。签发方式选 webroot, 因为 80 端口已经有 Nginx 在跑,用 --webroot 不需要停服务:

sudo certbot certonly --webroot -w /var/www/html \
  -d api.example.com \
  --agree-tos -m you@example.com --no-eff-email

原理是 HTTP-01 校验:CA 会请求 http://你的域名/.well-known/acme-challenge/某串, certbot 把文件写进 webroot 目录,Nginx 从那儿读出来,CA 看到内容匹配就签发。 所以 80 端口必须能从公网访问,而且不能有跳转到 HTTPS 的强制规则挡在 ACME 路径前面。

签完在 /etc/letsencrypt/live/你的域名/ 下会有两个关键文件: fullchain.pem(证书 + 中间证书)和 privkey.pem(私钥)。 配置里必须用 fullchain,只用 cert.pem 会缺中间证书, 浏览器能过(会自己补),但小程序和很多 API 客户端会直接判证书链不完整。

ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
ssl_protocols       TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;

add_header Strict-Transport-Security "max-age=31536000" always;

续期靠 certbot.timer,装包时自动启用。确认一下:

systemctl is-active certbot.timer
systemctl list-timers | grep certbot

四、443 超时和 443 拒绝,完全不是一回事

这一节是整晚最有价值的收获。证书签好了、nginx -t 通过、systemctl reload 成功, 但 https://域名 打不开。从外网测:

node -e "require('net').connect({host:'1.2.3.4',port:443,timeout:5000})"

结果是超时,不是报错。这两个现象指向完全不同的原因:

现象含义查哪儿
连接超时 包在网络层被丢掉,根本没到服务器 云平台防火墙 / 安全组
连接被拒绝 包到了,但没有程序在这个端口监听 ss -lntp | grep 443

超时的时候,服务器上可能一切正常——ss -lntp 显示 nginx 在 443 监听,本机 curl -k https://127.0.0.1 也通。问题只在云平台的防火墙规则里。 轻量应用服务器要在控制台的「防火墙」页签单独放行 443,和系统里的 ufw/iptables 是两套东西。

判断方法很简单:同时测一个已知可用的 baidu.com:443。如果那个通、你的不通, 就排除本地网络问题,锁定在目标端。

五、Nginx 只暴露该暴露的东西

接口反代到本机 3000,其余路径一律 404:

location /api/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

location / {
    return 404;
}

Node 只监听 127.0.0.1:3000,不绑 0.0.0.0。 这样即使防火墙配错了,3000 端口也不可能从公网直接访问。

管理后台这类东西,不要靠「路径猜不到」来保护。它的访问入口应该被彻底关掉: 在 nginx 里对 /admin 返回 404,需要时用 SSH 隧道临时打开——

ssh -N -L 3000:127.0.0.1:3000 user@your-server
# 然后本地浏览器访问 http://127.0.0.1:3000/admin

这样后台在公网上不存在,而不是「存在但需要密码」。

六、限流

limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
...
location /api/ {
    limit_req zone=api burst=60 nodelay;
}

30 r/s 单 IP 足够任何正常客户端使用,主要防的是脚本刷接口。 burst=60 nodelay 允许短时突发,避免用户连续点几下就被拦。

七、验收清单

部署完成后按这个顺序验,每一项都有明确的期望值:

# 1. 服务活着
curl https://api.example.com/healthz
#   期望 200,正文里有 ok

# 2. 证书可信(自签会失败)
curl -I https://api.example.com/healthz | head -1

# 3. 未带凭证的接口应被拒
curl -X POST https://api.example.com/api/xxx
#   期望 401

# 4. 不存在的路径应 404
curl -I https://api.example.com/nothing-here

# 5. 证书链完整
openssl s_client -connect api.example.com:443 -servername api.example.com \
  </dev/null 2>/dev/null | grep -E "Verify return|Protocol"
#   期望 Verify return code: 0 (ok),Protocol 为 TLSv1.2 或 TLSv1.3

第 5 条最容易被跳过,但它是唯一能确认「中间证书带齐了」的方式。 前四条全过、小程序仍然报证书错误的情况,基本都是这里的问题。

八、回头看,真正的教训

技术细节之外,这次最大的收获是关于脚本的失败方式:

以及一个操作层面的习惯:先确认自己在哪台机器上。 我在这上面浪费了很久——改了半天的配置在另一台服务器上,两台机器主机名都是 VM-0-x-ubuntu,只有 IP 不同。