跳至内容
·5 分钟阅读

真正好用的 VPS 配置:Node.js、PM2、Nginx 与零停机部署

我在生产环境中实际使用的 VPS 部署方案——Ubuntu 安全加固、PM2 集群模式、Nginx 反向代理、SSL,以及一个至今没让我失望过的部署脚本。没有理论,只有实战。

分享:X / TwitterLinkedIn

这个博客运行在一台每月 10 美元的 VPS 上。不是 Vercel,不是 AWS,不是由六个人管理的 Kubernetes 集群。就是一台 Ubuntu 服务器,配上 Nginx、PM2 和一个不到 30 秒就能完成部署的 bash 脚本。

我试过其他路线。用过 Vercel(挺好的,直到你需要定时任务、持久 WebSocket 连接或者只是想要掌控感)。用过 AWS(挺好的,如果你喜欢把半天时间花在 IAM 策略上的话)。我最终总是回到 VPS。

但问题是:网上每一篇"部署到 VPS"的教程都只讲了顺利的情况。它们教你安装 Node.js,运行 node server.js,就把它叫做生产环境了。然后你的服务器被 SSH 暴力破解,你的进程在凌晨 3 点挂了因为没人配进程管理器,你的 SSL 证书三个月前就过期了。

这是我希望当初就有的指南。这里的一切都经过实战检验——这个完全相同的配置正在提供你正在阅读的这个页面。

先从安全开始,代码放后面#

在你想到 Node.js 之前,先锁定服务器。新开的 VPS 实例就是靶子。自动化机器人在你开通服务器的几分钟内就会开始攻击你的 SSH 端口。

创建非 root 用户#

bash
adduser deploy
usermod -aG sudo deploy

配置 SSH 密钥认证#

在你的本地机器上:

bash
ssh-copy-id deploy@your-server-ip

然后完全禁用密码认证:

bash
sudo nano /etc/ssh/sshd_config
bash
PasswordAuthentication no
PermitRootLogin no
bash
sudo systemctl restart sshd

如果你跳过这一步,几天之内你就会在认证日志里看到成千上万的失败登录尝试。这不是偏执——在公共互联网上这就是日常。

用 UFW 配置防火墙#

bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

就这样。四条规则。只有 SSH 和 Web 流量能通过。

Fail2Ban#

bash
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

编辑 /etc/fail2ban/jail.local

ini
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
bash
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

三次 SSH 登录失败就被封禁一小时。我亲眼看过 Fail2Ban 在一天之内封禁了数百个 IP。它真的有效。

无人值守安全更新#

bash
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgrades

你的服务器现在会自动安装安全补丁。少一件需要记住的事。

Node.js:用 NVM,不要用 apt#

我在每个教程里都看到:sudo apt install nodejs。别这么做。

Ubuntu 的软件包仓库附带的是古老的 Node.js 版本。即使是 NodeSource PPA 也会落后。当你需要在不同项目之间切换 Node 20 和 Node 22 时,你就卡住了。

NVM 解决了这个问题:

bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default lts/*

验证一下:

bash
node -v  # v22.x.x 或者当前的 LTS 版本
npm -v

不太显而易见的提示:当你用 NVM 安装全局包(比如 PM2)时,它们是绑定到那个 Node 版本的。如果你用 nvm use 切换版本,你的全局包就消失了。在服务器上设好默认版本然后就别动了:

bash
nvm alias default 22

这个坑我踩过一次。一次就够了。

PM2:名副其实的进程管理器#

PM2 是"部署了"和"生产就绪"之间的区别。它处理进程管理、集群化、日志轮转、崩溃后自动重启和启动脚本。而且免费。

安装和配置#

bash
npm install -g pm2

生态系统配置文件#

不要用命令行参数启动应用。使用 ecosystem.config.js 文件。它可以版本控制、可复现、而且是自文档化的。

javascript
// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: "akousa",
      script: "node_modules/.bin/next",
      args: "start -p 3002",
      cwd: "/var/www/akousa.net",
      instances: 2,
      exec_mode: "cluster",
      max_memory_restart: "500M",
      env: {
        NODE_ENV: "production",
        PORT: 3002,
      },
      // 优雅关闭
      kill_timeout: 5000,
      listen_timeout: 10000,
      wait_ready: false,
      // 日志
      log_date_format: "YYYY-MM-DD HH:mm:ss Z",
      error_file: "/var/log/pm2/akousa-error.log",
      out_file: "/var/log/pm2/akousa-out.log",
      merge_logs: true,
      // 失败时自动重启
      autorestart: true,
      max_restarts: 10,
      min_uptime: "10s",
      // 生产环境不要 watch
      watch: false,
    },
  ],
};

让我解释一下那些重要的选择:

instances: 2 而不是 "max":在只有 1-2 核的小 VPS 上,"max" 听起来聪明,但它会产生在构建时争夺资源的进程。两个实例既能实现零停机重载,又留出了余量。在 4 核以上的机器上,当然可以用 "max"

exec_mode: "cluster":这就是启用零停机重载的关键。没有集群模式,pm2 reload 不过是花哨的重启。有了集群模式,PM2 逐个重启实例——你的应用永远不会完全下线。

max_memory_restart: "500M":你的 Next.js 应用有内存泄漏?PM2 会在它 OOM 导致服务器崩溃之前重启它。这已经不止一次让我免于凌晨 2 点的告警。

kill_timeout: 5000:给你的应用 5 秒钟完成正在处理的请求,然后 PM2 才强制终止它。默认值(1600ms)对有数据库连接的应用来说太激进了。

watch: false:我见过有人在生产环境留着 watch: true。PM2 会在每次日志文件变化时重启应用。你的应用陷入重启循环。千万别这样。

启动脚本#

让 PM2 在重启后存活:

bash
pm2 startup systemd
# 复制并运行它输出的命令
pm2 save

这会生成一个 systemd 服务。服务器重启后,你的应用会自动恢复。测试一下——重启你的服务器然后验证。不要想当然。

日志轮转#

日志最终会吃光你的磁盘空间。安装轮转模块:

bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true

每个文件最大 50MB,保留 7 个轮转文件,压缩旧文件。如果不设置这个,我见过一个中等流量的应用在三周内把 25GB 的 /var/log 塞满。

Nginx:比你想象的做得更多的反向代理#

"为什么不直接把 Node.js 暴露在 80 端口?"

因为 Nginx 处理的是 Node.js 不应该浪费 CPU 周期去做的事情:SSL 终止、静态文件服务、gzip 压缩、请求缓冲、连接限制,以及优雅地处理慢客户端。它是用 C 编写的,专为此目的打造。

安装#

bash
sudo apt install nginx -y

配置#

nginx
# /etc/nginx/sites-available/akousa.net
 
upstream node_app {
    server 127.0.0.1:3002;
    keepalive 64;
}
 
server {
    listen 80;
    listen [::]:80;
    server_name akousa.net www.akousa.net;
 
    # 将所有 HTTP 重定向到 HTTPS
    return 301 https://$host$request_uri;
}
 
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name akousa.net www.akousa.net;
 
    # SSL(由 Certbot 管理 — 这些行会自动添加)
    ssl_certificate /etc/letsencrypt/live/akousa.net/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/akousa.net/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
 
    # 安全头
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
 
    # Gzip 压缩
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_min_length 256;
    gzip_types
        text/plain
        text/css
        text/javascript
        application/javascript
        application/json
        application/xml
        image/svg+xml
        application/wasm;
 
    # 代理设置
    location / {
        proxy_pass http://node_app;
        proxy_http_version 1.1;
 
        # 头信息
        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;
 
        # WebSocket 支持(如果你需要的话)
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
 
        # 超时 — 宽裕但不是无限
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
 
        # 缓冲 — 让 Nginx 处理慢客户端
        proxy_buffering on;
        proxy_buffer_size 16k;
        proxy_buffers 4 32k;
        proxy_busy_buffers_size 64k;
    }
 
    # Next.js 静态资源 — 让 Nginx 直接提供服务
    location /_next/static/ {
        alias /var/www/akousa.net/.next/static/;
        expires 365d;
        access_log off;
        add_header Cache-Control "public, immutable";
    }
 
    # 公共静态文件
    location /static/ {
        alias /var/www/akousa.net/public/static/;
        expires 30d;
        access_log off;
    }
 
    # 阻止访问隐藏文件
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

启用配置:

bash
sudo ln -s /etc/nginx/sites-available/akousa.net /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

重载之前一定要运行 nginx -t 我曾经推了一个有问题的配置,导致网站宕机,就因为跳过了语法检查。nginx -t 这五个字符本可以帮我省下三十分钟的紧急调试时间。

大多数教程在这个配置中遗漏的几点:

keepalive 64upstream:Nginx 复用与 Node.js 后端的连接,而不是每个请求都打开一个新的 TCP 连接。这在高负载时很重要。

proxy_buffering on:Nginx 把 Node.js 的整个响应读入内存,然后以客户端能接受的任何速度发送给它。没有这个,一个在 3G 网络上的慢客户端会占用你的 Node.js worker。

直接提供 _next/static/ 服务:这些是经过哈希的不可变资源。让 Nginx 从磁盘提供它们,并设置 365 天的缓存头。你的 Node.js 进程不应该在这上面浪费时间。

五分钟搞定 SSL#

Let's Encrypt 解决了 SSL 问题。如果你在 2026 年还在花钱买证书,别买了。

bash
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.net

Certbot 会要求你提供邮箱、接受服务条款,然后自动修改你的 Nginx 配置以包含 SSL 指令。就是这样。

验证自动续期#

Certbot 安装了一个 systemd 定时器,每天检查两次,在证书到期前 30 天内自动续期:

bash
sudo systemctl list-timers | grep certbot

测试续期是否正常工作:

bash
sudo certbot renew --dry-run

如果测试通过,你就再也不用想 SSL 的事了。如果失败,通常是因为 80 端口被阻止了(检查你的 UFW 规则)或者 Nginx 没在运行。

有一个让我栽过跟头的地方:如果你在运行 Certbot 之前就配置了 Nginx,确保你的 server 块先在 80 端口监听,不要加 HTTPS 重定向。Certbot 需要访问 80 端口来完成 HTTP-01 验证。Certbot 成功运行后,添加重定向。

部署脚本#

这是我每次推送到生产环境时运行的脚本。没有 CI/CD 平台,没有 GitHub Actions。只有 SSH 和 bash。

bash
#!/bin/bash
# deploy.sh — 接近零停机的部署
 
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
APP_NAME="akousa"
LOG_FILE="/var/log/deploy.log"
HEALTH_URL="http://localhost:3002"
MAX_RETRIES=10
RETRY_INTERVAL=3
 
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
 
log "=== 部署开始 ==="
 
cd "$APP_DIR"
 
# 拉取最新代码
log "正在拉取最新变更..."
git pull origin main 2>&1 | tee -a "$LOG_FILE"
 
# 安装依赖
log "正在安装依赖..."
npm install --legacy-peer-deps 2>&1 | tee -a "$LOG_FILE"
 
# 构建
log "正在构建应用..."
rm -rf .next
npm run build 2>&1 | tee -a "$LOG_FILE"
 
if [ $? -ne 0 ]; then
    log "错误:构建失败。正在中止部署。"
    exit 1
fi
 
# 重载 PM2(集群模式下零停机)
log "正在重载 PM2..."
pm2 reload "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
pm2 save 2>&1 | tee -a "$LOG_FILE"
 
# 带重试的健康检查
log "正在运行健康检查..."
for i in $(seq 1 $MAX_RETRIES); do
    HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo "000")
    if [ "$HTTP_CODE" = "200" ]; then
        log "健康检查通过 (HTTP $HTTP_CODE)"
        log "=== 部署成功完成 ==="
        exit 0
    fi
    log "健康检查尝试 $i/$MAX_RETRIES (HTTP $HTTP_CODE)。${RETRY_INTERVAL} 秒后重试..."
    sleep $RETRY_INTERVAL
done
 
log "错误:健康检查在 $MAX_RETRIES 次尝试后失败"
log "正在回滚到之前的 PM2 状态..."
pm2 restart "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
exit 1

设为可执行:

bash
chmod +x deploy.sh

从本地机器部署:

bash
ssh root@your-server-ip "bash /var/www/akousa.net/deploy.sh"

这个脚本中的关键决策:

set -euo pipefail:脚本在任何错误时立即退出。没有这个,一个失败的 npm install 会默默继续到构建步骤,然后你得到一个隐晦的错误,浪费 20 分钟来调试。

构建前 rm -rf .next:Next.js 有一个构建缓存,偶尔会产生过期的输出。我被这个坑过一次——页面显示旧内容,尽管源代码已经更新了。清除构建目录可能多花 15 秒,但保证了输出是全新的。

pm2 reload 而不是 pm2 restart:这就是零停机的部分。在集群模式下,reload 执行滚动重启——它用更新的代码启动新实例,等它们就绪,然后优雅地关闭旧实例。在任何时刻都不会出现零实例运行的情况。

带重试的健康检查:Next.js 在重启后需要几秒钟来预热。脚本等待最多 30 秒(10 次重试 x 3 秒),检查应用是否以 HTTP 200 响应。如果没有,说明出了问题,你需要立刻知道——而不是从用户那里得知。

失败时回滚:如果健康检查在所有重试后仍然失败,脚本重启 PM2(加载上次保存的状态)。这不是完美的回滚,但比让服务器处于崩溃状态要好。

凌晨两点出问题的时候#

以下是我在这个完全相同的配置上实际调试过的问题:

"网站挂了"#

第一批要运行的命令:

bash
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.log

十次里有九次,pm2 logs 会立刻告诉你发生了什么。缺少环境变量、数据库连接失败,或者未处理的 Promise 拒绝。

"内存一直在涨"#

bash
pm2 monit

这会给你一个实时的仪表盘,显示每个进程的 CPU 和内存使用。如果内存稳步攀升而不平稳下来,你就有内存泄漏。生态系统配置中的 max_memory_restart 设置是你的安全网——PM2 会在进程拖垮服务器之前重启它。

深入调查的话:

bash
pm2 describe akousa

这显示运行时间、重启次数和内存快照。如果你看到过去 24 小时内有 47 次重启,那就是线索。

"SSL 证书过期了"#

bash
sudo certbot certificates

列出所有证书及其到期日期。如果自动续期失败了:

bash
sudo certbot renew --force-renewal
sudo systemctl reload nginx

"磁盘满了"#

bash
df -h
du -sh /var/log/*
pm2 flush

pm2 flush 会立即清除所有 PM2 日志文件。如果你没有设置日志轮转(我提醒过你),这就是你感受痛苦的地方。

我每天早上运行的命令#

bash
ssh deploy@akousa.net "pm2 status && df -h / && uptime"

一行命令搞定三件事:我的进程是否在运行,磁盘空间是否正常,服务器是否过载。只需两秒钟。在用户发现之前就能抓住问题。

大多数教程不会告诉你的事#

构建步骤是你最大的脆弱点。 在 1GB 内存的 VPS 上,Next.js 应用的 npm run build 可以消耗 800MB 以上的内存。如果在构建期间 PM2 以两个实例运行你的应用,你会 OOM。解决方案:使用交换文件(至少 2GB),或者在构建期间停止应用并接受几秒钟的停机。我用交换文件。

bash
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

安装命令中的 --legacy-peer-deps 是代码异味,不是解决方案。 我用它是因为依赖树中的一些包还没有更新它们的对等依赖范围。每隔几个月我会尝试去掉它。总有一天会成功的。在那之前,我先把东西发布了。

从零开始测试你的部署脚本。 在一台全新服务器上克隆你的仓库,手动运行每一步。隐藏在部署脚本中的"在我机器上没问题"的问题数量令人尴尬。我这样做的时候发现了三个问题——缺少全局包、文件权限错误,以及一个只因为之前的手动配置才存在的路径。

把服务器 IP 写进你的 SSH 配置。 别再手打 IP 地址了:

bash
# ~/.ssh/config
Host akousa
    HostName 69.62.66.94
    User deploy
    IdentityFile ~/.ssh/id_ed25519

现在只需要 ssh akousa 就够了。小事情会积少成多。

完整清单#

在你宣布完成之前:

  • 有 sudo 权限的非 root 用户
  • 仅 SSH 密钥认证,密码认证已禁用
  • UFW 已启用,只开放必要端口
  • Fail2Ban 保护 SSH
  • 无人值守安全更新已启用
  • 通过 NVM 安装的 Node.js
  • PM2 以集群模式运行你的应用
  • PM2 启动脚本已配置(重启后存活)
  • PM2 日志轮转已安装
  • Nginx 反向代理配置正确的头信息
  • 通过 Let's Encrypt 获取 SSL 并启用自动续期
  • 带健康检查的部署脚本
  • 交换文件已配置(为构建留出余量)
  • 已测试:重启服务器并验证一切正常恢复

最后一项是人们跳过的。别做那种人。重启服务器,等 60 秒,然后检查你的应用是否在线。如果不在,你的启动脚本配置有误,而你会在最糟糕的时候发现这个问题。

这算"企业级"吗?#

不算。这就是重点。

这套配置以每月不到 10 美元的成本可靠地运行着这个博客。用一条命令 30 秒内完成部署。我理解其中的每一个部分。出了问题,我确切地知道该看哪里。

我能用 Docker 吗?当然。我能用 Kubernetes 吗?技术上可以。我能搭建一套完整的 CI/CD 流水线,配上暂存环境和金丝雀部署吗?完全可以。

但我学到的是,最好的基础设施是你真正理解的、凌晨两点能调试的、而且花费不超过项目收入的那种。对于个人网站、SaaS MVP 或小型创业公司来说——这就是那套配置。

先发布。需要时再扩展。而且永远、永远要在全新服务器上测试你的部署脚本。