Node.js 项目在本地 npm start 跑通,和它在 VPS 上稳定跑一年,完全是两件事。前者只要端口不冲突就行;后者要处理进程崩了自动拉起、服务器重启后自己恢复、内存慢慢涨上去被 OOM 杀掉、证书到期忘记续、日志把磁盘写满这一堆事。
这篇按 Ubuntu 24.04 LTS 走一遍完整流程:机器怎么选、Node.js 24 LTS 怎么装、systemd 和 PM2 怎么选、Nginx 反向代理怎么写、HTTPS 怎么配、环境变量放哪、日志在哪看、以及怎么做到部署时不掉线。示例应用监听 127.0.0.1:3000,Express、Koa、Nest、Fastify 都一样。
用 node app.js 直接起服务,关掉 SSH 窗口进程就没了,服务器重启也不会回来。这是新手最常踩的第一个坑。
长期运行需要四个东西:
| 需求 | 靠什么解决 |
|---|---|
| 崩溃自动重启、开机自启 | systemd 或 PM2 |
| 对外提供 80/443 端口、绑域名 | Nginx(或 Caddy)反向代理 |
| 证书自动续期 | certbot 或 Caddy 内置 ACME |
| 出问题能查 | journalctl / PM2 日志 / Nginx error.log |
少任何一个,出问题时你都会在凌晨两点对着一个没有任何报错的"网站打不开"发呆。
Node.js 是单线程事件循环,单核性能比核数重要。一个普通的 API 服务,2 核 2G 就能撑住几千日活;瓶颈通常出现在数据库连接池、内存和磁盘 IO,而不是 CPU 核数。
| 场景 | 配置 | 说明 |
|---|---|---|
| 个人项目、小 API、练手 | 2 vCPU / 2 GB / 40 GB SSD | 加 2 GB Swap,够用 |
| 正式业务、带 SSR 或构建 | 2-4 vCPU / 4 GB / 80 GB SSD | 内存是主要瓶颈 |
| 多进程 cluster / 带数据库同机 | 4 vCPU+ / 8 GB+ | 别把数据库和构建任务挤在一起 |
几个实际会踩到的点:
- 构建比运行吃资源。
next build、vite build在 1 核机器上可能跑十几分钟甚至被 OOM 杀掉。构建放 CI,或者临时升配。 - 内存要留余量。Node 默认堆上限跟物理内存挂钩,容器里如果不设
--max-old-space-size,很容易把整个机器拖垮。 - 磁盘选 SSD/NVMe。
node_modules几万个小文件,机械盘或低 IOPS 的盘装依赖能等到怀疑人生。
按用途挑厂商的话:小项目省钱可以看 Racknerd,年付价格低,洛杉矶和圣何塞节点够用;要按小时计费、随时换机房,Vultr 上手快;要大内存大盘,Contabo 性价比高但磁盘 IO 一般;国内访问要求高的,搬瓦工 和 DMIT 的 CN2 GIA 线路稳定,代价是贵。Hetzner 的价格和性能都不错,但要注意机房位置对国内访问不友好。
具体怎么按参数选,可以对着 VPS 配置怎么选 那张表过一遍。
先把系统基础包和 Docker 之外的东西装齐:
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl git build-essential ufw
sudo timedatectl set-timezone Asia/Shanghai
build-essential 别省。bcrypt、sharp、sqlite3 这些包要本地编译,缺编译器会报一堆看不懂的 gyp 错误。
生产环境用 NodeSource 的 apt 源,理由是升级简单、不需要登录 shell 加载 nvm 环境(systemd 里加载 nvm 特别麻烦):
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
node -v && npm -v
版本选择上要留意:截至 2026 年 9 月,Node.js 24 是 Active LTS,Node 22 已进入维护期(维护到 2027 年 4 月),Node 26 还是 Current。Node 24 会在 2026 年 10 月 20 日转入维护期,Node 26 随后在 10 月 28 日成为 LTS。所以:
- 现在新上线的项目,直接用 Node 24。
- 10 月底之后开新项目,可以考虑 Node 26。
- Node 22 不要再用于新项目,老项目排期升级。
版本状态随时可以在 Node.js 官方发布计划 核对,别信博客里的过期说法。
如果你更习惯 nvm(多版本切换、开发机),它当然也能用,最新版是 v0.40.7:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.7/install.sh | bash
source ~/.bashrc
nvm install 24 && nvm alias default 24
但你要接受一个后果:systemd 服务读不到 nvm 的环境变量。解决办法是在 unit 文件里写死绝对路径(/home/deploy/.nvm/versions/node/v24.x.x/bin/node),或者干脆不用 nvm。为了少一类玄学问题,我更推荐生产机用 NodeSource。
不要用 root 跑 Node 进程。一个依赖里的原型污染漏洞,就足够把整台机器交出去。
sudo useradd -r -m -d /srv/app -s /bin/bash appuser
sudo mkdir -p /srv/app/{releases,shared}
sudo chown -R appuser:appuser /srv/app
目录结构后面会用到:
/srv/app/
├── releases/ # 每次发布的代码,按时间戳分目录
│ ├── 20260920-101500/
│ └── 20260925-090000/
├── current -> releases/20260925-090000
└── shared/
├── .env # 环境变量,不进 git
├── uploads/ # 用户上传文件,软链进 release
└── node_modules/ # 可选,跨 release 复用依赖
拉代码用 deploy key(只读)而不是把私钥拷到服务器上:
sudo -u appuser ssh-keygen -t ed25519 -C "deploy@vps" -f /srv/app/.ssh/id_ed25519 -N ""
sudo -u appuser cat /srv/app/.ssh/id_ed25519.pub # 贴到仓库的 Deploy Keys
sudo -u appuser git clone [email protected]:your/repo.git /srv/app/releases/20260920-101500
两个都能用,取舍看你需要什么:
| systemd | PM2 | |
|---|---|---|
| 依赖 | 系统自带,零额外依赖 | 需要 npm 安装 |
| 多进程 cluster | 要写多个 unit 或模板 unit | -i max 一行搞定 |
| 日志 | journalctl 统一管理,可限容量 | 自己写文件,需配 pm2-logrotate |
| 内存超限自动重启 | 支持 MemoryMax,超了直接杀 | 有 max_memory_restart,但重启前会先抖 |
| 部署时重载 | systemctl reload(需应用支持信号) | pm2 reload 原生滚动重启 |
| 出问题时的排错 | 资料多、和其他服务一致 | 多一层抽象,偶尔遇到 PM2 自身的坑 |
我的建议:单进程应用、或者你希望运维方式统一,用 systemd;需要开满 CPU 核数的 cluster 模式,用 PM2。
/etc/systemd/system/myapp.service:
[Unit]
Description=My Node.js App
After=network.target
[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/srv/app/current
EnvironmentFile=/srv/app/shared/.env
ExecStart=/usr/bin/node --max-old-space-size=768 /srv/app/current/server.js
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
# 资源与安全限制
MemoryMax=1G
LimitNOFILE=65535
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/app/shared
[Install]
WantedBy=multi-user.target
几个关键点:
EnvironmentFile指向shared/.env,不要放在 release 目录里,否则每次发布都会丢。--max-old-space-size=768要比MemoryMax=1G小,留出堆外内存(Buffer、原生模块)的空间。堆上限设得比容器/unit 上限还高,结果是内核 OOM Killer 直接杀进程,连堆栈都没有。ProtectSystem=strict会把整个文件系统挂成只读,所以必须显式ReadWritePaths声明要写的地方(上传目录、缓存目录、.env)。忘了这条,应用会以EROFS报错启动失败。
启用:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
sudo npm install -g pm2@7
在 current 目录下建 ecosystem.config.js:
module.exports = {
apps: [{
name: 'myapp',
script: 'server.js',
instances: 'max',
exec_mode: 'cluster',
max_memory_restart: '700M',
node_args: '--max-old-space-size=640',
env_file: '/srv/app/shared/.env',
error_file: '/srv/app/shared/logs/err.log',
out_file: '/srv/app/shared/logs/out.log',
merge_logs: true,
time: true,
listen_timeout: 10000,
kill_timeout: 5000,
}],
};
启动并设置开机自启:
sudo -u appuser pm2 start ecosystem.config.js
sudo -u appuser pm2 save
sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u appuser --hp /srv/app
pm2 startup 这条命令生成的 unit 是给 root 的,一定要带 -u appuser,否则重启后 PM2 会以 root 身份拉起你的应用,等于白做了权限隔离。
日志必须配轮转,不然 ~/.pm2/logs 能涨到几十 G:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 20M
pm2 set pm2-logrotate:retain 7
cluster 模式下有个坑:多个 worker 共享同一个端口由 PM2 负责分发,但内存里的会话、定时任务、内存缓存会各自一份。如果你的应用用 setInterval 跑定时任务,cluster 模式下会跑 N 遍。这种情况要么改成单实例,要么把定时任务拆出去单独跑。
应用监听 127.0.0.1:3000,Nginx 负责 80/443。这样应用不用 root 权限(1024 以下端口需要特权),也不用自己处理 TLS。
sudo apt install -y nginx
/etc/nginx/sites-available/api.example.com:
upstream myapp_backend {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name api.example.com;
client_max_body_size 20m;
access_log /var/log/nginx/api.access.log;
error_log /var/log/nginx/api.error.log warn;
location / {
proxy_pass http://myapp_backend;
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;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering on;
}
# WebSocket / SSE
location /socket.io/ {
proxy_pass http://myapp_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_buffering off;
}
}
sudo ln -s /etc/nginx/sites-available/api.example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
容易漏的几处:
X-Forwarded-For不设,应用拿到的客户端 IP 永远是127.0.0.1。Express 记得开app.set('trust proxy', 1),否则限流按 IP 统计会全站互相误伤。client_max_body_size默认只有 1M,上传大文件直接 413。这个和 Nginx 安全加固 里的限速规则要一起考虑。- SSE 和长轮询必须
proxy_buffering off,否则事件会被 Nginx 攒着不发。 proxy_read_timeout默认 60s,跑长任务的接口要单独放宽。
想要最省事,用 Caddy 代替上面整段 Nginx 配置,两行就完事:
api.example.com {
reverse_proxy 127.0.0.1:3000
}
Caddy 自己申请和续期证书,配置写法看 Caddy 反向代理完全指南。
已经用 Nginx 了,就加 certbot:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d api.example.com --redirect --agree-tos -m [email protected]
续期是 systemd timer 自动跑的,但你得确认它真的在跑:
systemctl list-timers | grep certbot
sudo certbot renew --dry-run
最尴尬的故障是"证书过期了没人发现"。建议接一个到期监控,或者用 Uptime Kuma 这类工具盯 HTTPS 有效期。续期失败的排查清单在 Let's Encrypt 证书续期失败怎么办。
.env 放 shared/ 下,权限收到 600:
sudo -u appuser touch /srv/app/shared/.env
sudo chmod 600 /srv/app/shared/.env
sudo chown appuser:appuser /srv/app/shared/.env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://app:[email protected]:5432/appdb
SESSION_SECRET=换成 openssl rand -hex 32 生成的值
三条纪律:
.env永远不进 git。仓库里放.env.example,只写键名不写值。- 数据库只监听 127.0.0.1,不要为了图方便把 5432/3306 开到公网,相关做法见 VPS 数据库不要直接暴露公网。
- 密钥轮换要有预案。
SESSION_SECRET一换,所有登录态失效,选在低峰期做。
每次发布直接覆盖代码目录,就得停机几十秒,而且回滚很痛苦。用软链接切换:
#!/usr/bin/env bash
set -euo pipefail
APP=/srv/app
RELEASE=$APP/releases/$(date +%Y%m%d-%H%M%S)
sudo -u appuser git clone --depth 1 [email protected]:your/repo.git "$RELEASE"
ln -sfn "$APP/shared/.env" "$RELEASE/.env"
ln -sfn "$APP/shared/uploads" "$RELEASE/uploads"
cd "$RELEASE"
sudo -u appuser npm ci --omit=dev
ln -sfn "$RELEASE" "$APP/current"
sudo systemctl reload myapp # 或 pm2 reload myapp
# 只保留最近 5 个版本
ls -1dt $APP/releases/* | tail -n +6 | xargs -r rm -rf
回滚就是把软链接指回上一个目录,再 reload 一次,几秒钟的事。
systemctl reload 只在应用自己监听 SIGUSR2 做了优雅重启时才有意义。如果应用没处理这个信号,reload 等价于 restart,仍然会断开正在处理的请求。 没做优雅退出的应用,更实在的做法是:先起新实例、健康检查通过后切换上游、再停老实例。
写部署脚本时记住 set -euo pipefail,否则 npm ci 失败了脚本还会继续执行,最后把一个空目录切成 current,线上直接 502。
# systemd 方式
sudo journalctl -u myapp -n 100 --no-pager
sudo journalctl -u myapp -f
# PM2 方式
pm2 logs myapp --lines 100
pm2 describe myapp
# Nginx
sudo tail -f /var/log/nginx/api.error.log
限制 journal 占用,避免日志吃满磁盘:
sudo journalctl --vacuum-size=500M
sudo sed -i 's/^#\?SystemMaxUse=.*/SystemMaxUse=500M/' /etc/systemd/journald.conf
sudo systemctl restart systemd-journald
502 Bad Gateway:Nginx 连不上后端。先看 systemctl status myapp,八成是应用启动失败或者监听在 localhost 而不是 127.0.0.1(IPv6/IPv4 解析差异会导致连不上)。
EADDRINUSE:端口被占。sudo ss -lntp 'sport = :3000' 找到进程,参考 VPS 端口被占用怎么办。
EACCES 监听 80 端口:别用 root 跑,交给 Nginx。非要直接监听低端口,就给二进制加 capability:sudo setcap 'cap_net_bind_service=+ep' $(which node),但注意每次 Node 升级后可能失效。
内存慢慢涨到被 OOM 杀掉:先用 --max-old-space-size 限制堆,再抓 heap snapshot 找泄漏。临时救急可以加 Swap,但 Swap 治不了泄漏,只能把崩溃时间推后。低内存优化的细节看 VPS 低内存怎么用 ZRAM 和 Swap 优化。
EMFILE: too many open files:unit 文件里的 LimitNOFILE 和内核 fs.file-max 一起调。
日志分析的整体思路可以对照 VPS 日志怎么看。
不想手动 SSH 上去跑脚本,可以用 Actions 推上去:
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: /srv/app/deploy.sh
三个 secret:VPS 地址、部署用户、部署私钥。私钥用专门的部署密钥,只给这一台机器用,泄露了随时能吊销。跑构建的机器可以看 GitHub Actions 自托管 Runner。
嫌这套太原始,直接上 Dokploy 或 Coolify,git push 自动构建部署,带面板和回滚。代价是多一层平台要维护,出问题时排查链路更长。
应用跑起来只是开始。下面这几件事漏了,出事只是时间问题:
- 备份:代码在 git 里不怕,数据库和上传目录丢了是真的丢。备份要演练过恢复才算数,流程参考 VPS 备份恢复演练。
- 流量:跑超了要么被限速要么被扣费,流量监控和超额预警 先把告警配上。
- 可用性:外部探针比你自己发现得早。Uptime Kuma 这类工具十分钟能配好。
- 安全更新:
unattended-upgrades只自动装安全补丁,内核更新仍需重启。 - 性能基线:上线时记下正常状态的 TTFB 和内存占用,出问题时才知道"变慢了"是相对什么而言。
- VPS 配置怎么选 — 拿不准买多大就先看这张表
- VPS Docker 部署实战 — 想改成容器化部署
- VPS Docker Compose 生产环境怎么配 — healthcheck、资源限制和回滚
- VPS 服务启动了但外网访问不了 — 端口、防火墙、监听地址排查
- VPS 网站 TTFB 很高怎么办 — 上线后发现慢
