真正好用的 VPS 配置:Node.js、PM2、Nginx 与零停机部署
我在生产环境中实际使用的 VPS 部署方案——Ubuntu 安全加固、PM2 集群模式、Nginx 反向代理、SSL,以及一个至今没让我失望过的部署脚本。没有理论,只有实战。
这个博客运行在一台每月 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 用户#
adduser deploy
usermod -aG sudo deploy配置 SSH 密钥认证#
在你的本地机器上:
ssh-copy-id deploy@your-server-ip然后完全禁用密码认证:
sudo nano /etc/ssh/sshd_configPasswordAuthentication no
PermitRootLogin nosudo systemctl restart sshd如果你跳过这一步,几天之内你就会在认证日志里看到成千上万的失败登录尝试。这不是偏执——在公共互联网上这就是日常。
用 UFW 配置防火墙#
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#
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local编辑 /etc/fail2ban/jail.local:
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600sudo systemctl enable fail2ban
sudo systemctl start fail2ban三次 SSH 登录失败就被封禁一小时。我亲眼看过 Fail2Ban 在一天之内封禁了数百个 IP。它真的有效。
无人值守安全更新#
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 解决了这个问题:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default lts/*验证一下:
node -v # v22.x.x 或者当前的 LTS 版本
npm -v不太显而易见的提示:当你用 NVM 安装全局包(比如 PM2)时,它们是绑定到那个 Node 版本的。如果你用 nvm use 切换版本,你的全局包就消失了。在服务器上设好默认版本然后就别动了:
nvm alias default 22这个坑我踩过一次。一次就够了。
PM2:名副其实的进程管理器#
PM2 是"部署了"和"生产就绪"之间的区别。它处理进程管理、集群化、日志轮转、崩溃后自动重启和启动脚本。而且免费。
安装和配置#
npm install -g pm2生态系统配置文件#
不要用命令行参数启动应用。使用 ecosystem.config.js 文件。它可以版本控制、可复现、而且是自文档化的。
// 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 在重启后存活:
pm2 startup systemd
# 复制并运行它输出的命令
pm2 save这会生成一个 systemd 服务。服务器重启后,你的应用会自动恢复。测试一下——重启你的服务器然后验证。不要想当然。
日志轮转#
日志最终会吃光你的磁盘空间。安装轮转模块:
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 编写的,专为此目的打造。
安装#
sudo apt install nginx -y配置#
# /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;
}
}启用配置:
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 64 的 upstream 块: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 年还在花钱买证书,别买了。
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.netCertbot 会要求你提供邮箱、接受服务条款,然后自动修改你的 Nginx 配置以包含 SSL 指令。就是这样。
验证自动续期#
Certbot 安装了一个 systemd 定时器,每天检查两次,在证书到期前 30 天内自动续期:
sudo systemctl list-timers | grep certbot测试续期是否正常工作:
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。
#!/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设为可执行:
chmod +x deploy.sh从本地机器部署:
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(加载上次保存的状态)。这不是完美的回滚,但比让服务器处于崩溃状态要好。
凌晨两点出问题的时候#
以下是我在这个完全相同的配置上实际调试过的问题:
"网站挂了"#
第一批要运行的命令:
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.log十次里有九次,pm2 logs 会立刻告诉你发生了什么。缺少环境变量、数据库连接失败,或者未处理的 Promise 拒绝。
"内存一直在涨"#
pm2 monit这会给你一个实时的仪表盘,显示每个进程的 CPU 和内存使用。如果内存稳步攀升而不平稳下来,你就有内存泄漏。生态系统配置中的 max_memory_restart 设置是你的安全网——PM2 会在进程拖垮服务器之前重启它。
深入调查的话:
pm2 describe akousa这显示运行时间、重启次数和内存快照。如果你看到过去 24 小时内有 47 次重启,那就是线索。
"SSL 证书过期了"#
sudo certbot certificates列出所有证书及其到期日期。如果自动续期失败了:
sudo certbot renew --force-renewal
sudo systemctl reload nginx"磁盘满了"#
df -h
du -sh /var/log/*
pm2 flushpm2 flush 会立即清除所有 PM2 日志文件。如果你没有设置日志轮转(我提醒过你),这就是你感受痛苦的地方。
我每天早上运行的命令#
ssh deploy@akousa.net "pm2 status && df -h / && uptime"一行命令搞定三件事:我的进程是否在运行,磁盘空间是否正常,服务器是否过载。只需两秒钟。在用户发现之前就能抓住问题。
大多数教程不会告诉你的事#
构建步骤是你最大的脆弱点。 在 1GB 内存的 VPS 上,Next.js 应用的 npm run build 可以消耗 800MB 以上的内存。如果在构建期间 PM2 以两个实例运行你的应用,你会 OOM。解决方案:使用交换文件(至少 2GB),或者在构建期间停止应用并接受几秒钟的停机。我用交换文件。
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 地址了:
# ~/.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 或小型创业公司来说——这就是那套配置。
先发布。需要时再扩展。而且永远、永远要在全新服务器上测试你的部署脚本。