我在生产环境中实际使用的 VPS 部署方案——Ubuntu 安全加固、PM2 集群模式、Nginx 反向代理、SSL,以及一个至今没让我失望过的部署脚本。没有理论,只有实战。
一台每月 10 美元的简朴 VPS —— 配上 Nginx、PM2 和 bash 脚本的单台 Ubuntu 服务器 —— 完全可以可靠地承载 Node.js 应用,无需 Vercel、AWS 或 Kubernetes 集群。
我试过其他路线。用过 Vercel(挺好的,直到你需要定时任务、持久 WebSocket 连接或者只是想要掌控感)。用过 AWS(挺好的,如果你喜欢把半天时间花在 IAM 策略上的话)。我最终总是回到 VPS。
但问题是:网上每一篇"部署到 VPS"的教程都只讲了顺利的情况。它们教你安装 Node.js,运行 node server.js,就把它叫做生产环境了。然后你的服务器被 SSH 暴力破解,你的进程在凌晨 3 点挂了因为没人配进程管理器,你的 SSL 证书三个月前就过期了。
这是我希望当初就有的指南。这里的一切都经过实战检验。
在你想到 Node.js 之前,先锁定服务器。新开的 VPS 实例就是靶子。自动化机器人在你开通服务器的几分钟内就会开始攻击你的 SSH 端口。
adduser deploy
usermod -aG sudo deploy在你的本地机器上:
ssh-copy-id deploy@your-server-ip然后完全禁用密码认证:
sudo nano /etc/ssh/sshd_configPasswordAuthentication no
PermitRootLogin nosudo systemctl restart sshd如果你跳过这一步,几天之内你就会在认证日志里看到成千上万的失败登录尝试。这不是偏执——在公共互联网上这就是日常。
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable就这样。四条规则。只有 SSH 和 Web 流量能通过。
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你的服务器现在会自动安装安全补丁。少一件需要记住的事。
我在每个教程里都看到: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 是"部署了"和"生产就绪"之间的区别。它处理进程管理、集群化、日志轮转、崩溃后自动重启和启动脚本。而且免费。
npm install -g pm2不要用命令行参数启动应用。使用 ecosystem.config.js 文件。它可以版本控制、可复现、而且是自文档化的。
// ecosystem.config.js
module.exports = {
apps: [
{
name: "myapp",
script: "node_modules/.bin/next",
args: "start -p 3002",
cwd: "/var/www/example.com",
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/myapp-error.log",
out_file: "/var/log/pm2/myapp-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 塞满。
"为什么不直接把 Node.js 暴露在 80 端口?"
因为 Nginx 处理的是 Node.js 不应该浪费 CPU 周期去做的事情:SSL 终止、静态文件服务、gzip 压缩、请求缓冲、连接限制,以及优雅地处理慢客户端。它是用 C 编写的,专为此目的打造。
sudo apt install nginx -y# /etc/nginx/sites-available/example.com
upstream node_app {
server 127.0.0.1:3002;
keepalive 64;
}
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# 将所有 HTTP 重定向到 HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
# SSL(由 Certbot 管理 — 这些行会自动添加)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/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/example.com/.next/static/;
expires 365d;
access_log off;
add_header Cache-Control "public, immutable";
}
# 公共静态文件
location /static/ {
alias /var/www/example.com/public/static/;
expires 30d;
access_log off;
}
# 阻止访问隐藏文件
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}启用配置:
sudo ln -s /etc/nginx/sites-available/example.com /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 进程不应该在这上面浪费时间。
Let's Encrypt 解决了 SSL 问题。如果你在 2026 年还在花钱买证书,别买了。
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d example.com -d www.example.comCertbot 会要求你提供邮箱、接受服务条款,然后自动修改你的 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/example.com"
APP_NAME="myapp"
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/example.com/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 myapp --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 myapp这显示运行时间、重启次数和内存快照。如果你看到过去 24 小时内有 47 次重启,那就是线索。
sudo certbot certificates列出所有证书及其到期日期。如果自动续期失败了:
sudo certbot renew --force-renewal
sudo systemctl reload nginxdf -h
du -sh /var/log/*
pm2 flushpm2 flush 会立即清除所有 PM2 日志文件。如果你没有设置日志轮转(我提醒过你),这就是你感受痛苦的地方。
ssh deploy@example.com "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 myapp
HostName YOUR_SERVER_IP
User deploy
IdentityFile ~/.ssh/id_ed25519现在只需要 ssh myapp 就够了。小事情会积少成多。
在你宣布完成之前:
最后一项是人们跳过的。别做那种人。重启服务器,等 60 秒,然后检查你的应用是否在线。如果不在,你的启动脚本配置有误,而你会在最糟糕的时候发现这个问题。
不算。这就是重点。
这套配置以每月不到 10 美元的成本可靠地运行着这个博客。用一条命令 30 秒内完成部署。我理解其中的每一个部分。出了问题,我确切地知道该看哪里。
我能用 Docker 吗?当然。我能用 Kubernetes 吗?技术上可以。我能搭建一套完整的 CI/CD 流水线,配上暂存环境和金丝雀部署吗?完全可以。
但我学到的是,最好的基础设施是你真正理解的、凌晨两点能调试的、而且花费不超过项目收入的那种。对于个人网站、SaaS MVP 或小型创业公司来说——这就是那套配置。
先发布。需要时再扩展。而且永远、永远要在全新服务器上测试你的部署脚本。