Nuxt项目部署全攻略:从SSR、Nginx到PM2的避坑指南 Nuxt 项目部署我踩过的坑比写的业务代码还多。刚开始是本地npm run dev一切正常一部署到服务器就白屏、接口 404、刷新掉路由查来查去最后发现九成的问题都出在“部署形态没确认”和“服务器环境跟本地不一致”这两件事上。这篇文章我就从一次完整的 Nuxt 项目部署出发把项目形态、构建命令、Nginx 配置、PM2 进程管理、环境变量这些环节一个个拆开讲清楚。不管你是买了服务器打算手动上线还是想搞明白自动化部署脚本里每一步在干什么照着这篇都能走通。内容对 Nuxt 2 和 Nuxt 3 都适用有差异的地方我会单独点出来。1. 部署前先确认形态SSR、静态化还是 SPA很多部署翻车的根子不是配置写错而是压根没搞清楚自己部署的是哪一种形态。Nuxt 项目看着都是一个模板但构建出来的东西完全不一样服务器处理方式也完全不一样。部署形态构建命令主要产物运行方式典型场景SSR服务端渲染nuxt build.output包含 server 逻辑和 public 静态资源Node 进程常驻请求实时处理首屏依赖接口数据、对 SEO 有要求纯静态生成nuxt generatedist或.output/public的静态文件Nginx 等 Web 服务器直接托管博客、文档站、内容更新不频繁SPA单页应用nuxt build且配置ssr: false静态入口 浏览器端渲染资源托管静态文件或用 Node 起一个简易服务后台管理、内部工具、无需 SEO判断方法也很简单如果你在代码里大量使用了asyncData、useFetch、useAsyncData并且希望这些请求在页面返回时就已经执行完那就是 SSR 形态如果你的页面内容是相对固定的构建时就能确定大部分路由对应的内容那可以考虑纯静态生成如果你只想要一个 Vue 单页应用不在乎首屏直接出完整 HTML那 SPA 模式就够了。我见过最典型的错误是把 SSR 项目的.output目录当成静态文件直接丢进 Nginx结果打开页面只有空白的index.html或者干脆看到目录列表。因为 SSR 构建产物里的index.mjs才是服务入口.output/public只是它托管的静态资源缺少 Node 进程页面自然跑不起来。反过来也有问题拿一个纯静态生成的站点强行跑到node .output/server/index.mjs上倒不是说不行而是白白占用内存还引入了一个完全不必要的守护进程。所以部署第一步不是急着敲命令而是先决定好“我做出来的这一个包到底是给谁用的”。1.1 为什么 SSR 不是默认最优解不少同学一看 Nuxt 默认是 SSR就觉得“能 SSR 肯定用 SSR”。实际不是这样的。SSR 每来一个请求服务端就要实时拉接口、渲染 HTML、再把流吐给浏览器对 CPU 和内存的消耗要比静态托管高一个量级。如果项目本身没有 SEO 诉求后台管理系统用 SSR 纯属自己给自己上强度。我维护的一个内部数据平台最初就是 SSR服务器只有 2G 内存高峰期直接 OOM后来改成了 SPA 模式问题立刻消失。因为这类系统只有一个入口内部员工也不靠搜索引擎进来完全没必要让 Node 进程包揽所有渲染。反过来公网站点、内容站点、电商类项目首屏 HTML 完整和服务端直出 SEO 是硬需求那 SSR 或静态生成才是合理选择。1.2 架构选型决定部署链路的复杂度这一步想清楚后续整个链路就顺畅了。静态生成和 SPA 只需要一个 Nginx 配置和静态文件目录几乎不涉及进程守护。SSR 则要多管理一个 Node 进程要用 PM2 之类的工具维护还要处理反向代理、日志、内存限制。所以在项目早期就确定形态比项目写了一半再切换划算得多。切换形态不是改一个配置就完事代码里的数据获取方式、路由守卫、中间件写法都可能要跟着调。部署只是把最后这层窗户纸捅破真正决定部署姿势的还是项目架构本身。2. Nginx 与 PM2 手动部署 SSR 版本的完整操作链路确认走 SSR 形态之后接下来就是完整的服务器部署操作。这里以 Linux 服务器为例假设你已经把项目源码放到了服务器上或者准备在本地构建后上传产物。两种方式我都用过各有适用场景。构建方式优点缺点适合场景服务器上直接构建环境一步到位不易出现本地和服务器产物不一致服务器需要安装完整依赖耗时长、吃内存服务器配置较高、项目不大本地构建后上传.output速度快、产物可控服务器只需要 Node 运行环境要处理好构建环境和运行环境的一致性服务器内存小、需要频繁发布我自己更倾向于第二种本地或 CI 里构建出.output然后把.output整个传到服务器。Nuxt 3 的.output/server目录里已经包含运行所需的生产依赖服务器上不需要重新npm install这能让发布流程变得非常干净。2.1 目录规划与权限设置服务器上不要随手把项目放在 home 目录或者/tmp下面时间一长自己都找不着。我习惯按下面这种方式规划/srv/www/nuxt-app/ ├── current - releases/20250601-1200 └── releases/ ├── 20250601-1200/ │ └── .output/ └── 20250601-0900/ └── .output/current是一个软链接指向当前在用的版本目录。发布新版本时新建一个带时间戳的目录把产物传进去然后把软链接指过去再重启进程。这样回滚也简单软链接指回上一个版本目录就行。目录创建完之后要确认运行用户有权限。如果服务器上用普通用户跑应用就给这个用户所属组加上目录的读写权限sudo mkdir -p /srv/www/nuxt-app/releases sudo chown -R deploy:deploy /srv/www/nuxt-app sudo -u deploy mkdir -p /srv/www/nuxt-app/releases/20250601-1200权限这一步看着不起眼但经常被忽略。我吃过一次亏上传完产物PM2 启动时报 EACCES 权限错误排查了半天才发现是目录 owner 不对文件传上去属主是 root普通用户启动进程根本没有写权限。所以每次建完目录、传完文件先ls -l看一眼属主和权限能省下不少排错时间。2.2 用 PM2 守护 Node 进程SSR 项目跑的是 Node 服务不能直接node .output/server/index.mjs挂在那里因为 SSH 窗口一关进程就跟着退出。PM2 的作用就是把这个 Node 进程托管起来负责自动重启、日志收集和开机自启。在服务器上安装 PM2 之后建议在项目里放一个ecosystem.config.cjs把进程相关的配置固化下来而不是每次手动敲一堆参数// ecosystem.config.cjs module.exports { apps: [ { name: nuxt-app, script: ./.output/server/index.mjs, cwd: /srv/www/nuxt-app/current, exec_mode: fork, instances: 1, autorestart: true, max_memory_restart: 500M, env: { HOST: 0.0.0.0, PORT: 3000, NODE_ENV: production, }, }, ], };这里exec_mode我建议先用fork不要急着上cluster。Nuxt 服务有内存缓存和内部状态多实例会带来更多状态同步问题。刚开始部署跑一个实例把链路跑通比盲目追求高可用更重要。等业务量确实上来了再根据实际瓶颈去扩容。启动命令很简单pm2 start ecosystem.config.cjs pm2 save pm2 startuppm2 save会把当前进程列表存到 dump 文件里pm2 startup用来设置开机自启。这两个命令很多人会漏掉导致服务器一重启应用就再也回不来了。2.3 Nginx 反向代理配置细节Node 进程虽然起来了但直接让用户访问 3000 端口不是好主意。一是端口直接暴露在外面不好管理二是静态资源、HTTPS、压缩这些事都应该交给 Nginx 处理。下面是一段基础但够用的 Nginx 配置server { listen 80; server_name example.com; 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_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; } location /_nuxt/ { alias /srv/www/nuxt-app/current/.output/public/_nuxt/; expires 30d; add_header Cache-Control public, immutable; } }两个重点说一下。第一个是proxy_set_header Upgrade和Connection upgrade这一行是为了 WebSocket 和 HMR 这类长连接请求做准备少了它某些依赖 WebSocket 的页面功能会出现连接断开的奇怪问题。第二个是/_nuxt/这个目录的alias配置把带哈希的静态资源直接交给 Nginx 托管并设置长缓存这样每次访问页面时Nginx 不必把所有静态资源都转发给 Node 进程压力会小很多。很多人会在这里栽在一个小区别上alias和root的路径拼接规则。root /path表示访问/foo/bar.js时Nginx 会去找/path/foo/bar.js而alias /path则表示访问/foo/bar.js时Nginx 找的是/path/bar.js。所以上面这个配置里location /_nuxt/配了alias /path/_nuxt/这种一对一的对应才正确。如果写反资源文件就全是 404页面打开后样式和 JS 全部加载失败。别忘了最后重载配置nginx -t systemctl reload nginxnginx -t是用来检查配置语法的必须养成习惯配置一定要先测试再 reload不然写错一个分号可能整台服务器上的站点全挂了。3. 环境变量和接口访问部署期最容易翻车的两个盲区部署 SSR 项目时环境变量的问题比想象中要多得多。本地开发时.env文件里写什么都是本地的一上线就要面对“构建时变量”和“运行时变量”两种完全不同的传递方式。3.1 构建阶段与运行时段的配置边界Nuxt 3 里通过runtimeConfig声明的变量是可以区分公开和私有的// nuxt.config.ts export default defineNuxtConfig({ runtimeConfig: { apiSecret: , public: { apiBase: , }, }, });runtimeConfig.public下的变量会被编译到浏览器端产物里浏览器可以直接读取。私有部分apiSecret只存在于服务端不会暴露给前端。对应的环境变量命名规则是NUXT_API_SECRET对应apiSecretNUXT_PUBLIC_API_BASE对应public.apiBase。在代码里用useRuntimeConfig()读取即可。关键的坑在于执行时机。如果你的变量名以NUXT_PUBLIC_开头那么它在构建阶段就会被写死进浏览器产物里。也就是说同一个 JS 文件你在 A 环境构建出来里面就是 A 环境的地址把这个文件原封不动放到 B 环境地址还是 A 环境的。如果项目要构建一次、部署到多个环境这类变量就只能在每个环境分别构建或者干脆放弃NUXT_PUBLIC_前缀改用运行时从服务端注入的方式。很多团队为了让前端能拿到接口地址习惯在.env.production里直接写NUXT_PUBLIC_API_BASEhttps://api.internal.example.com这样一构建地址就进了前端 chunk等于把内网服务地址暴露给了所有访问者还容易出现跨域问题。更合理的做法是前端只配置一个相对路径/api实际转发逻辑交给 Nginx。3.2 用 Nginx 反向代理解决 API 跨域开发环境跑得好好的上线就跨域是部署期最常见的问题。原因很简单开发时 Nuxt 服务帮你做了代理浏览器访问同源的/api生产环境如果代码里写死了后端地址浏览器就会直接请求另一个域名跨域随之而来。与其在后端写一堆 CORS 配置不如在前端 Nginx 这层把接口请求代理到后端服务保持前后端同源location /api/ { proxy_pass http://127.0.0.1:8080/api/; 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_pass后面如果带了/api/这个路径Nginx 会把浏览器请求里的/api前缀替换成代理目标里的/api也就是原样转发如果proxy_pass不带路径比如http://127.0.0.1:8080那浏览器请求/api/user时后端收到的还是/api/user。两种写法的路径拼接规则不同必须根据后端接口的真实路径来定。采用这种方案之后前端代码里的apiBase设成空字符串或者/api即可所有环境共用一套构建产物后端地址也只在服务器配置里出现。既避开了跨域也避免了把内部接口信息带到浏览器端。4. 部署踩坑实录四个高频问题的排查过程手动部署 Nuxt 项目该走的坑我基本都走过。这一节不直接给答案我把排查思路也写出来遇到类似问题的时候你至少知道从哪里下手。4.1 页面刷新 404 与 Nginx fallback 配置现象很经典从首页点进去一切正常但直接在浏览器地址栏输入一个子路由地址刷新就是 404。这里要先分清项目形态。如果是纯静态生成或 SPA 模式那 404 基本是 Nginx 没有做单页应用 fallback 导致的。/about这个路径在服务器磁盘上并不存在一个叫about的文件Nginx 找不到就直接返回 404而单页应用希望把所有未知路径都交给入口index.html处理。修复很简单在静态站点的 server 配置里加上location / { try_files $uri $uri/ /index.html; }如果你部署的是 SSR那就不应该出现这种 404。SSR 项目把所有请求都交给 Node 进程处理路由由 Nuxt 服务端解析Nginx 只需要proxy_pass转发过去就行。如果 SSR 也出现刷新 404优先检查是不是把 request 错误转发给了静态目录或者location里有没有多了一层没有 fallback 的子路径。4.2 端口可访问性排查链路第二个高频问题是页面在服务器本机curl 127.0.0.1:3000能正常返回 HTML但浏览器从外网就是访问不到。排查链路先从进程看起。用ss -lntp确认端口是否在监听监听地址是0.0.0.0还是127.0.0.1。如果 PM2 配置里HOST写的是127.0.0.1那 Nginx 在服务器本机转发没问题但外网直连这个端口永远不通。SSR 应用对外只开放 80/443 即可Node 进程绑 127.0.0.1 反而是更安全的选择。第二步检查防火墙。先是系统防火墙iptables -L -n ufw status再检查服务器控制台里的安全组规则。很多云服务器有双重防护系统 firewall 放行了 80 端口控制台安全组却没放行外网请求直接被挡在更外层。这时候从服务器本机测试永远正常从外部测试死活不通就是这个原因。第三步检查 Nginx 是否真的把请求转发到了 Node。查看 Nginx 错误日志tail -f /var/log/nginx/error.log我遇到过一种情况Nginx 配置里proxy_pass http://127.0.0.1:3000;的3000端口前面后台服务已经换到了 3001Nginx 转发过去的是一个没人监听的端口错误日志里全是connect() failed (111: Connection refused)。改回正确端口问题就解决了。4.3 构建内存溢出与 Node 版本带来的坑服务器配置不够时构建 SSR 项目很容易出现内存溢出。报错信息通常是FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory这是 Node 进程默认堆内存不够用的表现。可以临时加大堆内存NODE_OPTIONS--max-old-space-size2048 npm run build也可以看一下服务器是不是没有 swap 交换空间。很多轻量服务器默认没开 swap内存一紧张就很容易 OOM。检查方法free -h如果 swap 那一行全是 0建议手动加一个 swap 文件比如 2G。不过这只能缓解不是根治方案。如果项目比较大我更推荐本地或 CI 构建后上传产物避免在低配服务器上跑完整构建。Node 版本不匹配也是个隐蔽问题。Nuxt 3 要求 Node 18 及以上如果你的服务器只装了 Node 14构建过程会各种报错有的报错信息还特别隐晦比如某个依赖包编译失败。建议部署前先node -v确认版本再用版本管理工具把 Node 切换到一个 LTS 版本别拿老环境硬扛。4.4 纯静态生成时动态路由页面缺失如果你用的是nuxt generate纯静态输出还要多考虑一个问题动态路由怎么处理。比如/article/123这种路由构建时 Nuxt 并不知道有哪些文章默认不会为每个文章 ID 生成静态页面。如果部署后用户直接访问这个地址就会变成 404。可以在nuxt.config.ts里配置prerender路由export default defineNuxtConfig({ nitro: { prerender: { routes: [/article/1, /article/2], }, }, });也可以配合一个脚本在构建前拉取所有文章 ID动态拼出路由列表喂给 prerender。如果文章数量很大或者内容实时性很强那别硬上纯静态老老实实用 SSR。5. 上线验证与日常发布维护清单部署完成不等于事情结束上线后怎么确认“真的在跑”以及以后每次改版怎么安全发布是另一个值得养成的习惯。5.1 部署状态验证的几个方法最简单的验证命令curl -I https://example.com/看返回码是不是 200看响应头里有没有server和content-type字段。如果是 SSRHTML 返回的应该是一段已经渲染好的完整页面而不是只有一个空的div。继续往下验证内容curl -s https://example.com/ | head -c 500如果你能看到页面标题、meta 描述甚至能看到服务端渲染出来的正文片段那基本说明 SSR 链路是通的。如果返回的 HTML 里只有一个div id__nuxt/div那就是没有走服务端渲染要么是跑成了 SPA要么是代理没生效。接口转发也值得单独验证一遍。用浏览器控制台看/api请求的响应状态或者直接curl -i https://example.com/api/health观察返回体和响应时间确认请求是经过 Nginx 转到后端没有 502、504。进程状态同样要确认pm2 status pm2 logs nuxt-app --lines 50pm2 status里看status是不是onlinerestarts有没有异常增长。如果restarts数字一直在涨说明进程在反复崩溃重启需要去翻日志定位原因。5.2 每次升级发布的推荐操作顺序发布这件事最忌讳直接改线上正在跑的文件。一旦出问题想回退都不知道从哪下手。我现在的流程固定是在服务器上创建新的 release 目录比如releases/20250601-1200把新构建的.output上传到这个目录用软链接把current切到新目录pm2 reload nuxt-app重启进程用上面的 curl 方法验证新版本确认无误后保留上一个 release 目录作为回滚备份回滚时只需要把软链接指回上个版本然后再执行一次pm2 reloadcd /srv/www/nuxt-app rm current ln -s releases/20250601-0900 current pm2 reload nuxt-app这套流程看着简单但实际用起来比在同一个目录里反复覆盖文件要稳得多。你永远不会遇到“文件传了一半应用已经读到残缺版本”的情况因为 Nginx 的root或alias指向的永远是软链接当前的目录切软链接是个原子操作进程重启之前用户访问到的还是旧版本。我最后再分享一个小技巧PM2 的reload和restart不一样restart是粗暴地杀掉进程再拉起reload会走更平滑的流程。如果后面接入了 CDN发布后还要注意缓存问题最好在每次发布后主动刷新一下 CDN 缓存或者在静态资源路径上强制带版本号。Nuxt 本身会给_nuxt下的资源生成哈希但入口 HTML 的缓存策略还是要单独设置别让 CDN 把旧页面缓存得太久。