将 Nuxt.js 项目部署到远程 Ubuntu VPS。当用户想要部署、上线、发布 Nuxt 应用到 VPS 或远程服务器时触发此技能。涵盖完整部署流程:本地构建验证门、SSH 密钥登录专用 deploy 用户(禁止 root)、PM2 通过 ecosystem.config.cjs 管理进程(进程名取自 package.json)、Nginx 反向代理、Let's Encrypt TLS、.env 服务器端注入。域名支持三种形态:裸域名(bare domain 永久重定向至 www)、www 子域名直接作为规范地址、其他二级或三级子域名(如 app.example.com、shop.store.example.com)直接提供服务。用户说部署 Nuxt、推送到 VPS、配置服务器 Nginx、PM2 Nuxt 部署、首次服务器配置等情形均应触发。
部署栈:本地构建验证 → SSH(id_rsa)→ deploy 用户 → PM2(node server)→ Nginx → Let's Encrypt。
首次服务器配置详见 references/server-first-time-setup.md。
只需向用户确认两项,其余通过自动探测得出:
1.2.3.4example.com → 规范地址为 www.example.com,裸域名做 301 重定向www 子域名,如 www.example.com → 同上app.example.com、shop.store.example.com → 规范地址即为该域名,无重定向伴侣不得询问"是否首次部署"——通过自动探测确定,见下一步。
获取 VPS IP 后立即执行,结果决定后续走哪条路径。
ssh -o ConnectTimeout=5 -o BatchMode=yes -i ~/.ssh/id_rsa deploy@<VPS_IP> "echo ok" 2>&1
ok:deploy 用户存在且密钥已授权 → 继续 1-Breferences/server-first-time-setup.md),完成后回到 1-Bdeploy 用户可登录后,一次性查清所有状态:
APP_NAME=$(node -p "require('./package.json').name")
ssh -i ~/.ssh/id_rsa deploy@<VPS_IP> << EOF
echo "=== 系统依赖 ==="
node -v 2>/dev/null || echo "node: 未安装"
pm2 -v 2>/dev/null || echo "pm2: 未安装"
nginx -v 2>&1 || echo "nginx: 未安装"
certbot --version 2>/dev/null || echo "certbot: 未安装"
echo "=== 本应用状态 ==="
ls /var/www/$APP_NAME 2>/dev/null && echo "app_dir: 存在" || echo "app_dir: 不存在"
ls /home/deploy/envs/$APP_NAME.env 2>/dev/null && echo "env_file: 存在" || echo "env_file: 不存在"
pm2 id $APP_NAME 2>/dev/null | grep -q '[0-9]' && echo "pm2_process: 存在" || echo "pm2_process: 不存在"
echo "=== 已占用端口 ==="
ss -tlnp | grep LISTEN
pm2 list --no-color
EOF
根据输出判断走哪条路径:
| 探测结果 | 路径 |
|---|---|
| 系统依赖有缺失 | 补装缺失依赖(见 references),然后继续 |
| app_dir 不存在 | 新应用首次部署:创建目录、分配端口、生成 ecosystem.config.cjs、配置 .env、配置 Nginx、申请证书,再走部署流程 |
| app_dir 存在,pm2_process 不存在 | 进程丢失恢复:不需要重新配置 Nginx 和证书,直接走部署流程重新注册进程 |
| app_dir 存在,pm2_process 存在 | 常规更新发版:直接走部署流程 |
| env_file 不存在 | 无论哪条路径,都必须先提示用户在服务器创建 .env 文件后再继续 |
root 用户 SSH。唯一例外:deploy 用户尚不存在时,root 仅用于执行首次配置中的用户创建步骤,完成后立即切换到 deploy。.output/ 就绪后才能传输。ecosystem.config.cjs 是进程名和端口的唯一来源。进程名必须与 package.json 的 name 字段一致。.env 只存在于服务器 /home/deploy/envs/<app-name>.env,不进代码仓库。.env,必须通过 PM2 的 env block 注入。pm2 kill、pm2 delete all、pm2 stop all 等影响全局的命令。1-B 的探测输出已包含端口信息,直接从中整理已占用端口,无需再单独查询。
端口分配惯例:从 3001 开始递增,遇到占用的跳过,取第一个空闲值。
若探测到同名 PM2 进程但确认属于不同项目,这是命名冲突,需和用户确认处理方案,不得强行覆盖。
每次发版强制执行,不得跳过。
pnpm build && pnpm preview
在浏览器访问预览地址确认页面正常加载。pnpm build 非零退出则停止,修复后重新验证。验证门通过后,.output/ 即为最终产物,后续直接传输,不再重复构建。
进程名通过 require('./package.json').name 动态读取,不得硬编码。PORT 在新应用首次部署时由端口探测结果确定,填入后提交到仓库;后续发版此文件已有正确端口,无需改动。
// ecosystem.config.cjs
const { name } = require('./package.json')
module.exports = {
apps: [
{
name,
script: '.output/server/index.mjs',
cwd: `/var/www/${name}`,
instances: 1,
exec_mode: 'fork',
env: {
NODE_ENV: 'production',
PORT: 3001, // 新应用首次部署时填入探测到的空闲端口,然后 git commit
},
},
],
}
.output/ 已就绪验证门(pnpm build && pnpm preview)必须已在本步骤之前完成。.output/ 即为验证通过的构建产物,直接使用,不再重复执行 pnpm build。
只传 .output 目录和 ecosystem.config.cjs,不传 node_modules。
注意:.output 不带尾斜杠,rsync 会在远端 $REMOTE_DIR/ 下创建 .output/ 子目录,与 ecosystem.config.cjs 中 script: '.output/server/index.mjs' 的路径保持一致。
APP_NAME=$(node -p "require('./package.json').name")
SERVER=deploy@<VPS_IP>
REMOTE_DIR=/var/www/$APP_NAME
rsync -az --delete \
-e "ssh -i ~/.ssh/id_rsa" \
.output \
ecosystem.config.cjs \
$SERVER:$REMOTE_DIR/
ssh -i ~/.ssh/id_rsa $SERVER << EOF
set -e
APP=$APP_NAME
cd /var/www/\$APP
# source 方式注入 .env,正确处理值中含空格的变量
set -a; source /home/deploy/envs/\$APP.env; set +a
# startOrReload:进程存在则热重载,不存在则启动。只影响本应用进程。
pm2 startOrReload ecosystem.config.cjs --update-env
pm2 save
EOF
ssh -i ~/.ssh/id_rsa $SERVER \
"pm2 status && pm2 logs $APP_NAME --lines 20 --nostream"
进程状态为 online,日志无启动报错,部署完成。
每个应用一套独立配置文件,放在 /etc/nginx/sites-available/<app-name>,symlink 到 sites-enabled。不得修改其他应用的配置文件。
根据域名形态选择模板:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://www.example.com$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name example.com;
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;
return 301 https://www.example.com$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name www.example.com;
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;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
}
}
Certbot:sudo certbot --nginx -d example.com -d www.example.com
server {
listen 80;
listen [::]:80;
server_name app.example.com;
return 301 https://app.example.com$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
}
}
Certbot:sudo certbot --nginx -d app.example.com
写入配置后:
sudo nginx -t && sudo systemctl reload nginx
nginx -t 失败时不执行 reload,排查后再重试。Certbot 申请完成后检查文件,若重定向逻辑被覆盖,恢复为对应模板结构。
确认自动续期定时器:sudo systemctl status certbot.timer
PM2 进程启动后立即崩溃 — 检查 /home/deploy/envs/<app>.env 是否包含所有必需的 key。运行 pm2 logs <app-name> --lines 50 查看实际报错。
Nginx 返回 502 Bad Gateway — 对比 ecosystem.config.cjs 中的 PORT 与 Nginx proxy_pass 端口,再用 pm2 status 确认进程在线。
TLS 证书未覆盖预期域名 — 重新执行对应模板的 Certbot 命令,检查 /etc/letsencrypt/renewal/<domain>.conf 中 domains 行。
端口被占用导致启动失败 — 用 ss -tlnp | grep <port> 找出占用者,修改 ecosystem.config.cjs 中的 PORT 为空闲端口并同步更新 Nginx upstream,提交后重新部署。禁止直接 kill 占用端口的进程,除非用户明确确认。
首次服务器配置,见 references/server-first-time-setup.md。