
先讲个我自己的经历。几年前我第一次部署一个基于 Nuxt 的 SSR 项目本以为和部署普通 Node 服务差不多结果本地跑得好好的一到服务器就各种 502、白屏、静态资源 404。后来一步步排查才发现问题出在 Node 版本、构建产物、进程守护和反向代理的整条链路上。从那以后我总结出一套 nuxt 项目部署的完整流程照着做基本不会再踩坑。这篇文章就把这套经验完整写出来从构建选型、环境准备、PM2 配置到 Nginx 反向代理再到常见的 502、内存溢出、环境变量丢失等问题的排查思路全部一次讲清。不管你是刚接触 Nuxt 的新手还是已经部署过几次但总在细节上翻车的老手这篇都能帮你省下几个小时。1. 部署前的整体思路拆解Nuxt 项目到底特殊在哪1.1 为什么不能像普通前端项目一样直接扔进 Nginx很多朋友部署 Vue 项目习惯了npm run build出来一个dist目录丢到 Nginx 的html目录里就完事了。但 Nuxt 项目不行或者说要看你怎么用。Nuxt 默认是 SSR服务端渲染模式也就是说请求到达服务器后Nginx 要把请求转发给一个 Node.js 进程由这个进程实时渲染出 HTML 再返回给浏览器。所以你的服务器上必须有一个常驻的 Node 进程在跑dist目录只是构建产物的一部分不是全部。你还需要node_modules里的运行时依赖、.nuxt目录里的服务端代码以及一套进程管理机制保证它不挂掉。如果你用的是nuxt generate做纯静态化部署那确实可以像普通前端项目一样扔进 Nginx但前提是你的项目里没有动态数据、没有用户登录态、没有服务端接口代理否则静态化之后这些功能会直接失效。1.2 三种部署模式的选型SSR、SPA、静态化Nuxt 3 里可以通过配置来决定最终部署形态我的经验是先想明白这一点再动手不然部署的时候会非常被动。部署模式配置方式适用场景服务器要求SSR默认开启无需额外配置需要 SEO、动态数据、用户交互复杂的站点必须 Node 环境 进程守护SPAssr: false后台管理系统、内部工具不追求首屏 SEO构建后纯静态资源可扔 CDN静态化nuxt generate/prerender内容型站点、博客、文档站内容基本固定任意静态服务器即可我见过不少人把 SSR 项目硬生生改成 SPA 部署只因为服务器上不会配 Node 进程结果 SEO 全废了。反过来也有把纯静态博客用 SSR 模式部署的白白占着内存。所以第一步就是确认你的场景到底需要哪种模式。1.3 本篇文章采用的方案清单我下面要讲的这套流程默认你用的是 SSR 模式这也是 Nuxt 部署里最典型、最容易出问题的一种。涉及到的核心工具有Node.jsLTS 版本Nuxt 3 要求 Node 18 及以上npm 或 pnpm包管理器二选一PM2Node 进程守护工具负责让服务常驻并开机自启Nginx反向代理负责把公网流量转发给本地 Node 端口Git代码传输部署时把代码推到服务器这套方案不需要 Docker因为对于中小型项目来说裸机 PM2 Nginx 是最省事、最容易排查问题的组合。Docker 有它的优势但如果你对容器不熟出了问题反而更难定位。2. 服务器环境准备与项目构建配置2.1 Node.js 版本怎么选这一条我必须放在最前面说因为版本不对后面全白搭。Nuxt 3 官方要求 Node 18 或更高版本但我实测下来 Node 18 和 Node 20 在内存占用和构建速度上有明显差异Node 20 的构建更快内存峰值稍微低一点。Node 22 目前也稳定了不过社区里有些依赖还没完全适配建议还是用 Node 20 LTS 最稳妥。如果你服务器上已经装了旧版 Node比如 16 或者 14直接用node -v检查一下不符合要求就先升级。升级方式我个人推荐用 nvm 管理curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash nvm install 20 nvm use 20 node -v装好之后把默认版本指过去或者写到.nvmrc文件里这样以后项目成员切版本也方便。2.2 本地构建还是服务器构建这个问题很多人纠结我的建议是直接在服务器上构建不要本地构建完再传上去。为什么因为 Nuxt 的构建产物里有部分内容是和操作系统、Node 版本相关的原生模块比如某些加密库、图片处理库你在本地 Mac 上构建出来的.nuxt产物传到 Linux 服务器上很可能跑不起来。就算跑起来了也可能因为 glibc 版本不一致出现奇奇怪怪的段错误。正确流程是本地把代码写好提交到 Git 仓库服务器上git clone或者git pull拉最新代码服务器上安装依赖服务器上执行npm run build服务器上用 PM2 启动这样保证构建环境和运行环境完全一致省掉大量排查时间。2.3 构建命令和产物结构Nuxt 3 的构建命令是npm run build执行完会出现两个关键目录.nuxtNuxt 内部生成的服务端和客户端代码运行时需要.outputNuxt 3 的服务器入口打包产物包含server/index.mjs和public/静态资源目录.output/server/index.mjs就是你要用 node 启动的文件。PM2 配置里指向它就行。这里有个点要注意如果你在服务器上执行npm run build时遇到内存不足多半是 Node 的默认堆内存不够用后面我会专门讲怎么调。2.4 依赖安装npm 还是 pnpm我个人强烈推荐用 pnpm原因很简单安装速度快、磁盘占用小、node_modules 结构清晰。但有两点要注意用 pnpm 安装依赖后启动时如果提示找不到某个包先检查是不是pnpm版本和项目的package.json里packageManager字段冲突如果你部署的服务器内存特别小比如 1Gnpm install比pnpm install更容易因为并发过高导致 OOM反而 pnpm 更稳安装命令pnpm install --frozen-lockfile--frozen-lockfile会严格按照pnpm-lock.yaml安装避免因为依赖版本漂移导致本地能跑、线上炸掉的经典问题。3. PM2 进程守护与 Nginx 反向代理配置实战3.1 PM2 配置文件不要用命令行参数硬启动很多人部署 Node 服务喜欢pm2 start npm -- run start这种临时命令对于生产环境我非常不建议。因为你的项目可能有多个环境测试、预发、生产每个环境的环境变量、端口、日志路径都不一样。把这些写死在命令行里换环境的时候就得改命令容易出错。正确做法是写一个ecosystem.config.cjs文件放到项目根目录module.exports { apps: [ { name: nuxt-app, exec_mode: fork, instances: 1, script: ./.output/server/index.mjs, env: { NODE_ENV: production, PORT: 3000, NITRO_PORT: 3000, NUXT_PUBLIC_API_BASE: https://api.example.com, }, max_memory_restart: 500M, log_date_format: YYYY-MM-DD HH:mm:ss, error_file: ./logs/err.log, out_file: ./logs/out.log, merge_logs: true, }, ], }几个参数我解释一下exec_mode: forkNuxt 3 的index.mjs本身不是 cluster 模式用 fork 最稳instances: 1除非你的服务器是多核且压力很大否则单实例足够了实例多了反而互相抢内存max_memory_restart: 500M内存超过 500M 自动重启这是防止内存泄漏把服务器拖死的保命符NUXT_PUBLIC_API_BASE这是 Nuxt 3 运行时公共环境变量的写法后面会细说启动命令pm2 start ecosystem.config.cjs pm2 save pm2 startuppm2 save是保存当前进程列表pm2 startup会生成一个开机自启脚本这样服务器重启后 PM2 会自动拉起你的应用不用每次手动去pm2 start。3.2 环境变量在 Nuxt 3 里的正确注入姿势Nuxt 3 的环境变量和 Vue 2 时期完全不同这里非常容易踩坑。Nuxt 3 里的环境变量分两类运行时环境变量代码里通过useRuntimeConfig()读取的变量名称以NUXT_为前缀启动时从进程环境变量里注入构建时环境变量打包时就被替换进代码里的变量比如NUXT_PUBLIC_开头的配置实战经验是凡是后端服务地址、内部密钥这些不能暴露给浏览器的用NUXT_前缀只存在于 PM2 的env配置里凡是要暴露给客户端浏览器使用的配置用NUXT_PUBLIC_前缀因为它会打进客户端 bundle举个小例子你的nuxt.config.ts里有runtimeConfig: { apiSecret: process.env.NUXT_API_SECRET, public: { apiBase: process.env.NUXT_PUBLIC_API_BASE || /api, }, }部署的时候PM2 的env里写NUXT_API_SECRETxxxx NUXT_PUBLIC_API_BASEhttps://api.example.com然后在页面里const config useRuntimeConfig() console.log(config.apiSecret) // 只在服务端有值 console.log(config.public.apiBase) // 客户端也能拿到这里有个坑如果你在.env文件里写环境变量然后本地npm run build这些变量会被编译进代码。但你部署时是在服务器上重新构建的所以服务器上必须有一份对应的.env或者通过 PM2 注入。我个人习惯是生产环境不用.env文件全部通过 PM2 的env注入这样git status永远是干净的不怕密钥被误提交。3.3 Nginx 反向代理配置详解PM2 把服务跑起来后默认监听3000端口。你不能让用户直接访问 3000 端口原因有两个裸端口看起来不专业而且用户记不住Nginx 做反向代理的同时可以顺便把 HTTPS、静态资源缓存、Gzip 压缩这些事一起干了这是我最常用的 Nginx 配置server { listen 80; server_name your-domain.com; # 将 http 请求重定向到 https return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name your-domain.com; ssl_certificate /etc/nginx/ssl/your-domain.com.pem; ssl_certificate_key /etc/nginx/ssl/your-domain.com.key; # 请求体大小限制如果项目里有文件上传这个值要调大 client_max_body_size 20m; location / { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } # 静态资源直接走 Nginx不经过 Node location /_nuxt/ { alias /var/www/nuxt-app/.output/public/_nuxt/; expires 30d; add_header Cache-Control public, immutable; } }这里有两个细节特别说明。第一个是proxy_set_header Upgrade和Connection upgrade这一组配置是给 WebSocket 用的。如果项目里有用到 WebSocket比如聊天室、实时通知少了这两行Socket 连接会握手失败。第二个是静态资源缓存。Nuxt 构建出来的_nuxt目录下的文件都带 hash 后缀文件名变了内容才变所以可以放心设置expires 30d和immutable。这样一来用户第二次访问页面的时候绝大多数静态资源直接命中浏览器缓存根本不请求服务器大幅降低 Node 进程压力。3.4 部署脚本一条命令搞定整个流程每次手动登录服务器敲那么多命令太累了我建议写一个简单的部署脚本放在项目deploy.sh里#!/bin/bash set -e echo 拉取最新代码... git pull origin main echo 安装依赖... pnpm install --frozen-lockfile echo 构建项目... npm run build echo 重启 PM2 进程... pm2 reload ecosystem.config.cjs --update-env echo 部署完成pm2 reload和pm2 restart的区别在于 reload 是零停机重载它先启动新进程接管流量再杀掉旧进程。对于 SSR 应用来说reload 能避免用户请求刚好撞上重启窗口导致的白屏。--update-env这个参数很重要它会强制 PM2 更新环境变量否则你改了ecosystem.config.cjs里的env配置不带上这个参数的话旧的进程里还是老环境变量。4. 部署过程中的典型问题与排查实录4.1 502 Bad Gateway最常见也最迷惑上线后一访问就 502这是 SSR 部署最经典的问题。502 的根源是 Nginx 无法连接到你配置的上游服务也就是 PM2 里管理的 Node 进程。排查路径我固定走这三步先看 PM2 进程活着没有pm2 list如果 status 显示errored或者stopped说明进程挂了再看进程日志pm2 logs nuxt-app --lines 100一般日志里会明确报错原因如果 PM2 显示进程活着但还是 502直接在服务器上curl http://127.0.0.1:3000如果 curl 不通说明应用监听失败如果能通就是 Nginx 配置的上游地址不对我碰到过最隐蔽的一种情况是PM2 进程是活的端口也能通但用户访问还是 502。查了半天发现 Nginx 的proxy_pass写的是http://localhost:3000而服务器上的localhost解析到了::1IPv6但 Node 只监听了 IPv4 的127.0.0.1。改成proxy_pass http://127.0.0.1:3000;就立刻好了。这个坑非常经典建议大家从一开始就写127.0.0.1而不是localhost。4.2 构建时内存溢出Javascript heap out of memory无服务器或者 1G 小内存服务器上构建 Nuxt 项目很容易看到这个错误FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory这就是 Node 默认堆内存上限不够用了。默认上限大概是 2G但小服务器物理内存可能只有 1G系统会先崩。解决办法是构建时显式调大堆内存我一般这样写NODE_OPTIONS--max-old-space-size1024 npm run build如果你的服务器有 2G 内存可以给到 1536 或 2048。这个值不是越大越好因为--max-old-space-size只是堆上限给你进程的物理内存配额就那么大设置超过实际可用物理内存反而会触发系统 OOM Killer进程被强制杀掉。另外一个关联问题是如果你的服务器内存确实太小比如 512M老实说不太建议跑 Nuxt SSR构建和运行都会很难受。要么升级配置要么把项目改成静态化部署。4.3 环境变量在构建后失真这个是 Nuxt 3 特有的坑我反复遇到。现象是这样的本地npm run dev的时候一切正常登录、接口都能通。到了服务器上页面能打开但所有接口请求都指向了localhost或者 API 地址不对。原因就是环境变量没有正确注入到构建过程。Nuxt 3 的runtimeConfig有一个特性从环境变量映射到 runtimeConfig 时构建时和运行时的优先级不一样。比如你在nuxt.config.ts里写了runtimeConfig: { public: { apiBase: process.env.NUXT_PUBLIC_API_BASE || /api } }这里process.env.NUXT_PUBLIC_API_BASE是在构建时读取的。如果你构建时这个环境变量不存在构建产物里写死的就是默认值/api。运行时你再往 PM2 里注入NUXT_PUBLIC_API_BASE已经晚了因为构建产物里的代码已经把/api当成默认值编译进去了运行时注入的变量不一定能覆盖已经内联的默认值。正确的做法是保证服务器构建时的环境变量和运行时一致或者在nuxt.config.ts的runtimeConfig里不要写process.env引用而是只写占位空字符串让运行时通过useRuntimeConfig()读取时动态填入我个人倾向于第一种方案因为更直观在构建命令前把环境变量带上NUXT_PUBLIC_API_BASEhttps://api.example.com npm run build这样可以确保构建产物里内联的就是线上正确的地址。4.4 静态资源 404 与缓存策略问题部署后 HTML 能打开但页面里的 JS、CSS 全部 404最常见的原因有这几个Nginx 的/_nuxt/路径 alias 写错了.output/public/_nuxt/这个目录路径没对上。我一般先ls /var/www/nuxt-app/.output/public/_nuxt/看看目录到底长什么样再去改 Nginx 配置。Nuxt 的 baseURL 配置变了如果你在nuxt.config.ts里设置了app.baseURL: /some-prefix/那所有静态资源的路径都会带上这个前缀Nginx 的 alias 也要对应改。缓存策略过激导致 HTML 被缓存有的朋友给整个站点设置了expires 30d结果页面 HTML 也被缓存了用户那边不管你怎么发布新版本永远看到的还是旧页面。我前面的配置里只对/_nuxt/目录做了长期缓存根路径location /没有设置expires这样 HTML 每次都是新鲜的。4.5 端口被占用和权限问题如果 PM2 启动失败日志里报EADDRINUSE: address already in use :::3000说明 3000 端口被别的进程占了。排查方式lsof -i :3000 kill -9 PID如果端口是 80 或者 443那大概率是之前的 Nginx 还没起来或者有别的 Web 服务占着同样用lsof查。关于权限我多说一句不要为了省事直接用 root 跑 PM2 或 Node 进程。生产环境更合理的做法是用一个单独的 deploy 用户把项目的属主改为这个用户然后在 Nginx 的proxy_pass里照常转发。这样万一应用被攻破攻击者拿到的是低权限用户的 shell不是 root。这个习惯越早建立越好。5. 部署后的验证与运维小技巧5.1 上线后的三项验证部署完成别急着关终端按下面三步快速验证第一验证首页能正常返回 HTML。用 curl 看首页响应curl -I https://your-domain.com检查响应头里有没有server: nginx以及 HTTP 状态码是否 200。如果你看到响应头里带着x-powered-by: Nuxt就不用太意外这是正常的。第二验证静态资源命中缓存。再 curl 一次静态文件看响应头里有没有Cache-Control: public, immutable。第三验证 SSR 是否真的生效。在浏览器里打开页面右键查看源代码确认页面 HTML 里有没有实际渲染出的内容。如果源代码里只有一个app空壳说明你的站虽然部署成功了但实际跑的是 SPA 模式SEO 会受影响。5.2 日志管理PM2 日志切割PM2 默认把所有日志写到同一个文件里时间长了会非常大影响性能。我习惯加一个pm2-logrotate插件pm2 install pm2-logrotate pm2 set pm2-logrotate:max_size 10M pm2 set pm2-logrotate:retain 7 pm2 set pm2-logrotate:compress true这样每个日志文件超过 10M 就自动切割保留最近 7 份老日志压缩存起来。运维久了你会发现日志轮转配置得好排查问题会非常舒服。5.3 更新发布时的优雅切换前面部署脚本里用的pm2 reload是零停机的但我补充一个细节如果你的项目有数据库迁移这类操作不能把迁移代码和应用发布混在一次部署里做。否则新代码起来的时候老的数据库结构还没迁移请求一到就报错。我的经验是涉及数据库变更的发布先手动执行迁移再用pm2 reload重启应用。这样应用起来的时候数据库已经是最新结构不会出现中间状态。5.4 多环境部署的推荐做法一个成熟的项目一般至少有三个环境开发、测试、生产。每个环境的 API 地址、密钥都不同。我最推荐的做法是给每个环境写一份独立的 PM2 配置比如ecosystem.config.test.cjs和ecosystem.config.prod.cjs然后在部署脚本里按环境参数加载。这一步做在前面你会省下非常多来回改环境变量的时间。比如说你要发测试环境pm2 start ecosystem.config.test.cjs --env test pm2 save这样测试环境自动会读测试的配置生产环境读生产的配置互不干扰。运维的人切换环境的时候也不会因为忘记改环境变量导致线上事故。6. 我踩过几次坑之后的个人体会Nuxt 项目部署这事说复杂不算特别复杂但每一步都有隐藏的细节。如果非要说哪些地方最容易让人翻车我的排序是环境变量注入、Node 版本不匹配、Nginx 的 location 和 alias 配置、PM2 的 env 更新时机。这四个坑我都在生产环境里真实踩过每个都能让线上服务在“看似正常”的状态里悄悄出问题。所以我最后再重复三个实用习惯服务器上构建不要在本地构建完再传产物环境变量全部通过 PM2 注入并保证构建时环境和运行时环境一致用ecosystem.config.cjs管理启动配置不要用命令行裸启动把这三条刻在脑子里你的 Nuxt 项目部署大概率一次就能跑通。真要是遇到玄学问题先看 PM2 日志再看 Nginx 错误日志/var/log/nginx/error.log大多数问题都在日志里写得明明白白。