Vue/React前端项目云服务器部署:Nginx+PM2实战指南

发布时间:2026/10/17 1:11:31
Vue/React前端项目云服务器部署:Nginx+PM2实战指南
把本地跑得好好的 Vue、React 项目部署到云服务器上结果打开浏览器只有一片空白或者 404——这件事这几年我见过太多次。不光是刚入门的朋友有些写过一两年项目的人也会在同样的地方栽跟头本地什么毛病都没有一上服务器就各种鬼故事。这篇文章就围绕 vue/react/web 前端项目部署到云服务器这件事把 nginx、pm2 这套流程连带着部署前的准备从架构选型讲到上线踩坑一次性说清楚。不管你是只做纯静态页面还是要托管带 Node 服务的 SSR 项目或者打算让 Nginx 做入口、PM2 托 Node 进程配合起来跑下面这套思路都适用。我自己最早部署前端项目时也干过“把 dist 文件夹手动拖到服务器”这种原始操作后来项目多了、线上出过几次事故才慢慢把整个流程沉淀成一套可复用的发布体系。这篇文章不会只丢给你几条命令而是把每条命令背后的“为什么”也讲明白这样你换一台服务器、换一个项目依然知道该怎么处理。1. 先想清楚项目架构静态托管、Node 进程还是 NginxPM2 组合很多人一上来就问“怎么部署”但我建议先问自己另一个问题这个前端项目到底需不需要 Node 进程这个问题不搞清楚后面所有配置都是瞎忙。1.1 三种部署模式对比纯静态、Node服务、混合架构先说最常见的三种情况。第一种是纯静态托管。Vue、React 这类 SPA 项目执行npm run build之后产出一个 dist 目录里面是 index.html、JS、CSS、图片这些静态文件整个项目没有任何服务端逻辑。这种项目只需要让 Nginx 指向 dist 目录把文件直接响应给浏览器就行简单、稳定、性能最好理论上不需要 PM2。第二种是带 Node 服务的项目。比如你用了 Nuxt、Next.js 的 SSR 模式或者自己写了一个 Express、Koa 服务来提供接口、处理服务端渲染那构建产物之外还有一个 server.js 之类的入口文件。这个 Node 进程必须常驻运行不能因为 SSH 窗口关了就挂掉也不能因为代码抛个异常就整个服务下线。这时候就轮到 PM2 出场了。第三种是混合架构也是目前最主流、最推荐的做法。Nginx 作为统一入口静态资源请求JS、CSS、图片、HTML由 Nginx 自己直接处理API 请求通过反向代理转发给 PM2 托管的 Node 进程。这样前端页面加载走 Nginx 的高速静态文件服务接口逻辑走 Node 进程两者互不干扰扩展和排错也都方便。1.2 不同架构下前置准备清单差异这三种架构需要准备的东西差别很大用一张表看更清楚。部署模式是否需要PM2Nginx核心职责构建产物典型场景纯静态托管不需要托管dist静态文件dist目录无后端逻辑的SPANode服务托管需要可不用或只做转发dist server.jsSSR、自带接口服务NginxPM2混合需要静态文件 API反向代理dist server.js前后端同仓部署这个选型直接决定了你接下来的操作步骤。纯静态项目安装 PM2 纯属多余反而增加维护成本反过来带 Node 服务的项目如果只做静态托管接口请求会全部 404。1.3 本地构建阶段就要注意的配置项架构想清楚之后部署前的第一道工序其实在本地就要完成。很多人忽略这步以为本地npm run build成功就等于万事大吉其实这里有几个坑。第一是 base 路径或 publicPath。如果你计划把项目部署到域名根路径默认的/就行但如果要部署在子路径下比如https://example.com/admin/就必须在构建配置里把 base 改成/admin/否则所有静态资源都会去根路径找页面白屏。第二是环境变量。接口地址、CDN 前缀这些变量开发环境和生产环境往往不一样。Vite 项目里通过.env.production区分Vue CLI 项目用VUE_APP_前缀React 用REACT_APP_前缀。构建完成后建议顺手在 dist 目录里 grep 一下确认接口地址确实是线上地址而不是http://localhost:3000。这一步能省掉你后面大量排查时间。第三是路由模式。如果你的 SPA 用了 HTML5 History 模式Nginx 就必须做路由回退如果用的 Hash 模式则不需要但 URL 会带#。我在 3.2 会专门讲回退配置。本地构建完强烈建议先起个静态服务预览一下 dist比如npx serve dist确认页面能正常打开再往服务器上送。2. 服务器初始环境准备Node 版本管理、Nginx 安装与 PM2 全局工具选好架构、本地构建没问题之后就可以开始折腾服务器了。我先按一套标准的 Linux 环境来操作下面所有命令在主流云服务器上都能跑通。2.1 用 nvm 管理 Node 版本别让包管理器装老版本新拿到一台服务器第一件事是更新系统包索引。以 Ubuntu 为例执行apt update apt upgrade -y。接下来是装 Node。这里我有一个很强烈的建议不要直接用apt install nodejs或yum install nodejs。各 Linux 发行版的软件源里 Node 版本通常都比较旧有些甚至还是 12.x、14.x而现代前端构建工具和 SSR 框架对 Node 版本要求很高版本太老跑不起来或者即使能跑也会遇到 ESM 语法报错、OpenSSL 版本兼容问题等一堆怪事。推荐用 nvm 管理 Node 版本。在服务器执行 nvm 的安装脚本装完source ~/.bashrc之后就能用了。然后用nvm install 20安装 LTS 版本再用nvm alias default 20把默认版本固定下来。这样每次重新登录服务器Node 都是你指定的版本。装完验证node -v和npm -v。如果 SSH 重新连接后 node 命令不见了通常是 nvm 的初始化脚本没被正确加载检查一下.bashrc里有没有 nvm 的初始化行。2.2 Nginx 安装与站点配置目录说明Nginx 的安装很简单Ubuntu 上apt install nginx -yCentOS 上yum install nginx -y。装完先执行nginx -v确认版本接着systemctl start nginx启动systemctl enable nginx让它开机自启。这里我想多说一下 Nginx 配置文件的目录结构因为很多人在这里迷路。Ubuntu 安装的 Nginx主配置是/etc/nginx/nginx.conf这个文件里通常有一行include /etc/nginx/conf.d/*.conf;。也就是说凡是在conf.d目录下的.conf文件都会被加载。因此最佳实践是不要直接改 nginx.conf而是在conf.d下面新建一个专门存放你站点配置的文件比如frontend.conf。这样以后维护起来清晰出问题也能快速定位。改完配置后一定要执行nginx -t检查语法确认无误后再systemctl reload nginx重载配置。记住reload是平滑重载不会中断现有请求restart会有短暂中断能用 reload 就不要 restart。2.3 PM2 全局安装与最小可用验证PM2 是 Node 生态里的进程管理器本质是一个全局安装的 npm 包。执行npm install -g pm2然后pm2 -v验证。到这里基础环境就齐了Nginx 负责网络层Node 负责业务层PM2 负责让 Node 进程活着。为什么要 PM2 而不直接node server.js你可以做个实验SSH 到服务器前台启动一个 Node 服务然后关掉 SSH 窗口。服务大概率直接没了。因为前台进程挂在 SSH 会话下面会话结束进程就被系统回收。更麻烦的是如果代码里有个未捕获异常导致进程崩溃没有任何机制会自动拉起它。PM2 解决的就是这两个问题daemon 化让进程脱离终端会话加上崩溃自动重启。到这里有人可能想问我是不是还需要装个数据库、装个 Docker看情况。如果项目依赖数据库那当然要装如果只是纯前端项目以上环境已经足够了。不要盲目追求多装东西服务器环境越简洁越容易排错。3. Nginx 配置四件套静态目录、路由回退、缓存压缩与 API 反向代理Nginx 的配置是前端部署里的重头戏。我用四个小节把一份生产可用的 Nginx 配置拆开揉碎讲清楚。3.1 最基础的静态站点 server 块模板先看一个最小的 server 块。假设我把前端项目放在/var/www/my-app/distserver { listen 80; server_name your-domain.com; root /var/www/my-app/dist; index index.html; location / { try_files $uri $uri/ /index.html; } }listen 80是监听 HTTP 端口server_name是你绑定的域名如果暂时没有域名可以直接填服务器公网 IP。root指令指定站点的根目录index index.html是说当请求是/的时候默认访问 index.html。这里要注意root和alias的区别。root会把完整的请求路径拼在目录后面比如请求/js/app.js实际找的是/var/www/my-app/dist/js/app.js这是前端静态托管最常用的方式。alias则会把 location 匹配的部分替换成指定目录适合把某个子路径映射到固定目录的场景比如把/static/映射到/var/data/static/。初学者很容易搞混这两个指令记住一个口诀root 拼接alias 替换。3.2 History 路由刷新 404try_files 回退原理SPA 项目用 History 路由时最经典的问题就是首页能打开但点进/about后刷新页面Nginx 返回 404。原因是这样的Vue 或 React 的路由是前端自己控制的URL 上的/about在服务器上并不存在一个真实的about.html文件。浏览器刷新时向服务器请求/aboutNginx 在 dist 目录里找不到这个文件就返回 404。try_files $uri $uri/ /index.html;这句配置就是为了解决这个问题。它的逻辑是先尝试按请求的 URI 找真实文件找到就返回找不到再尝试找目录再找不到就回退到/index.html。这样所有前端路由都交给 index.html由前端路由器接管页面就能正常渲染了。如果你的项目部署在子路径比如location /admin/回退写法要稍微调整。可以这样写location /admin/ { alias /var/www/my-app/dist/; try_files $uri $uri/ /admin/index.html; }总之用了 History 路由就一定要做回退这是 SPA 部署的高频坑能理解原理就不怕换项目了。3.3 Gzip 压缩与静态资源缓存策略Nginx 配置里还有两项优化不能少Gzip 压缩和静态资源缓存。压缩很好理解对 JS、CSS 这类文本文件启 Gzip传输体积能减少 60% 到 80%首屏加载速度会有肉眼可见的提升。在 server 块里加几行gzip on; gzip_types text/plain text/css application/javascript application/json image/svgxml; gzip_min_length 1024;gzip_min_length 1024的含义是只有文件大小超过 1KB 才压缩避免压缩小文件反而浪费 CPU。缓存策略就更有讲究了。前端构建产物里的 JS、CSS 文件名通常带 hash 戳比如app-4f8a2c.js内容是变了文件名就变所以这类文件非常适合强缓存可以放心地设成一年。但 index.html 是入口文件如果也设了长缓存每次发版用户拿到的都是旧页面必须特殊处理。建议这样配置location ~* \.(js|css|png|jpg|svg|woff2)$ { expires 30d; add_header Cache-Control public, immutable; } location /index.html { add_header Cache-Control no-cache; }第一块负责带 hash 的静态资源直接告诉浏览器放心缓存一个月。第二块单独命中 index.html用no-cache意思是浏览器每次使用前都必须向服务器确认一下版本但可以协商缓存如果文件没变就返回 304不会重复下载全文。3.4 API 反向代理Nginx 与 PM2 进程协作的关键混合架构里Nginx 还要干一件大事把 API 请求转发给 PM2 托管的 Node 进程。假设你的 Node 服务监听在 3000 端口前端构建时把接口地址配成相对路径/api那 Nginx 配置长这样location /api/ { proxy_pass http://127.0.0.1:3000; 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后面到底加不加/。加了/表示把 location 中匹配的部分替换掉比如请求/api/user会被转发成http://127.0.0.1:3000/user不加/表示保留完整路径转发成http://127.0.0.1:3000/api/user。后端接口定义里有没有/api前缀决定了你要不要加这个斜杠。这三行proxy_set_header是让后端能拿到客户端的真实 IP 和域名。如果没有 Host 头后端服务在某些框架下会认为请求来源异常。我见过有同事漏配X-Real-IP导致线上日志里所有请求的 IP 都是127.0.0.1排查问题难上加难。4. PM2 进程托管启动配置、开机自启与日志排查接下来是 PM2 的实战。这里我不建议你每次都用pm2 start server.js这种零散命令而是用配置文件把启动参数固化下来。4.1 ecosystem.config.js将启动参数固化下来在项目目录下新建一个ecosystem.config.jsmodule.exports { apps: [ { name: my-app, script: server.js, cwd: /var/www/my-app, instances: 1, exec_mode: fork, env_production: { NODE_ENV: production, PORT: 3000 } } ] };name是进程名以后所有 PM2 操作都用这个名字比用 PID 或 script 路径方便得多。cwd指定工作目录确保server.js里的相对路径不会出错这是很容易忽略但影响很大的配置。exec_mode: fork表示单进程模式对大多数前端 SSR 项目或轻量接口服务够用了如果想榨干多核 CPU可以改成cluster模式并设instances: max但要注意如果你的 Node 应用有内存共享需求或使用了 WebSocketcluster 模式会引入额外复杂度简单场景不建议上来就用。启动命令是pm2 start ecosystem.config.js --env production。注意--env production对应的是配置里的env_production字段它会自动设置NODE_ENV和PORT这两个环境变量。4.2 进程保活、开机自启的原理与操作PM2 最核心的价值是进程保活。进程崩溃后PM2 会自动拉起默认无限次重启。但还有一个容易被忽略的点如果 Node 进程是在服务器启动之前就配置好的那服务器重启后 PM2 管理的进程怎么办PM2 提供了两条命令解决开机自启问题。第一步执行pm2 startup systemd这个命令会提示你需要运行一条sudo env PATH... pm2 startup systemd -u ...之类的命令复制它执行即可。第二步执行pm2 save把当前进程列表保存到 PM2 的 dump 文件里。这样服务器每次开机systemd 都会自动启动 PM2 并恢复之前的进程列表。操作完成后建议你手动重启一次服务器验证。很多人漏了这个验证结果半年后机房重启服务没起来都不知道是为什么。重启后pm2 list应该能看到my-app处于 online 状态。4.3 日志查看与清理线上排错第一站进程跑起来之后日志就是你最亲密的排错工具。PM2 默认会把进程的标准输出和标准错误写到两个日志文件里路径通常在~/.pm2/logs/my-app-out.log和~/.pm2/logs/my-app-error.log。日常查看日志最常用的是pm2 logs my-app实时打印当前日志如果要看历史错误直接tail -f ~/.pm2/logs/my-app-error.log就行。还有个快捷键pm2 logs my-app --lines 200能只看最近 200 行排查问题时比从头翻快很多。日志文件会无限增大时间长了可能占满磁盘。解决这个问题有两个思路一是装pm2-logrotate模块做自动轮转按大小和时间切割日志二是设定定时任务手动清理。我个人的建议是如果只是个人项目直接用一个简单的 crontab 把超过 7 天的日志删掉就行没必要为日志管理单独引入模块。5. 发布与回滚流程构建产物同步、版本软链接和上线检查清单环境配好、服务跑通这只是开始。真正体现部署水平的是发布流程和回滚能力。一个成熟的发布流程应该做到线上出问题时能在几秒钟内回到上一个稳定版本。5.1 本地构建与 rsync 同步产物如何安全到达服务器发布的第一步永远是本地执行npm run build。构建在本地而不是服务器上执行好处是构建环境可控不占用服务器资源。产物怎么到服务器早期很多人用宝塔面板手动上传能跑但不够工程化。我推荐用 rsync通过 SSH 协议增量同步只传输变动的文件速度快且安全。命令长这样rsync -avz --delete dist/ rootyour-server-ip:/var/www/my-app/releases/20250101_1200/-a是归档模式保留权限和时间戳-z是压缩传输-v是显示进度--delete确保本地删除的文件在服务器上也同步删除。要注意的是dist/后面这个斜杠非常关键源目录带斜杠表示把目录里的内容同步到目标目录不带斜杠会把 dist 目录本身也套进去直接导致路径错层。这个细节坑过很多人。5.2 版本目录加软链接让回滚变得简单可靠同步到服务器的产物不要直接往当前线上目录里覆盖。我强烈建议用“版本目录 软链接”的方案。目录结构是这样的/var/www/my-app/ ├── current - /var/www/my-app/releases/20250101_1200 └── releases/ ├── 20241201_1800/ ├── 20241215_1000/ └── 20250101_1200/每次发布时把新产物同步到带时间戳的新目录然后执行ln -sfn /var/www/my-app/releases/20250101_1200 /var/www/my-app/current把current软链接切换到新目录。Nginx 的 root 路径配置成/var/www/my-app/current/dist这样每次发布 Nginx 配置完全不用动。回滚就更简单了把软链接指回上一个版本目录一条命令的事ln -sfn /var/www/my-app/releases/20241215_1000 /var/www/my-app/current这个方案的本质是把线上发布变成一个原子的符号链接切换动作没有覆盖式发布的“中间状态”不会出现发布到一半网站打不开的情况。如果发布后发现异常回滚就是一瞬间的事。相比改 Nginx 配置、删旧目录这些做法这个方案的容错性要好得多。5.3 发布检查清单上线前最后一遍过发布经验多了之后我给自己总结了一套上线检查清单每次发布前逐条对照宁可慢一分钟也别漏一项。本地npm run build是否成功dist 目录里 index.html 和静态资源是否齐全构建产物里的接口地址、CDN 前缀等环境变量是否正确服务器磁盘空间是否充足df -h确认新版本目录是否已经同步完成目录列表检查一下nginx -t语法检查通过必要时执行 reload安全组是否放行了 80/443 端口域名解析是否指向当前服务器 IPPM2 日志里有没有启动报错pm2 logs my-app --lines 100 --err扫一遍线上curl -I http://你的域名确认返回 200再看一眼响应头里 Cache-Control 是否正常curl -sI http://你的域名/index.html这套清单看起来简单但每一条都对应着我踩过的具体坑。比如第六步安全组我第一次部署时就因为忘了放行 80 端口折腾了大半天后来发现所有配置都是对的。6. 高频踩坑记录从端口放行到缓存穿透最后这部分我把这些年线上部署遇到的高频问题集中写出来。这些问题在文档里都写得很简单但实际发生时各有各的隐蔽性。6.1 安全组端口放行最容易忽略的先决条件云服务器和物理服务器最大的区别是除了主机本身的防火墙还有一层云平台的安全组。很多次帮别人排查问题SSH 能连上、Nginx 配置没问题、进程也在跑但浏览器就是打不开最后一看安全组入方向规则只放行了 22 端口80 和 443 没放行。处理方式很简单登录云服务商控制台找到安全组配置添加入方向规则放行 TCP 80 和 443。这个步骤应该在拿到服务器第一天就完成不要等到部署好了才想起来。另外如果你项目里还需要 WebSocket记得把对应端口也一并放行。6.2 文件权限导致 403Nginx 无法读取前端目录Nginx 安装后worker 进程默认是以www-data用户运行的。如果你的前端目录放在/root或某个普通用户的家目录下www-data用户根本没有权限进入这些目录访问时就会返回 403。最佳实践是把站点目录统一放在/var/www下并执行chown -R www-data:www-data /var/www/my-app这块踩坑很有意思headers 都是通的页面静态资源 404而且只在部分路径下 404。原因是某个子目录的权限被上一个部署流程改掉了。排查的时候可以用sudo -u www-data ls /var/www/my-app/dist模拟 Nginx 用户去读目录能读通说明权限没问题不能读就按权限处理。6.3 浏览器缓存导致“新版本没生效”的真相发完版本刷新页面前端还是旧代码这是我被问过最多的问题之一。大部分时候不是发布失败是浏览器缓存。如果第一次部署时 index.html 没有设置 no-cache而 CDN 或 Nginx 给它加了很长的缓存时间用户端就会一直拿着旧页面。解决办法就是 3.3 里说的index.html 必须每次向服务器确认带 hash 的静态资源可以放心缓存。开发调试时也可以临时强制刷新快捷键 CtrlShiftR但根上的方案还是配置要正确。6.4 PM2 端口冲突与 Node 进程假死的处理最后说一个 PM2 场景下很典型的错误端口冲突。表现为pm2 restart后日志里出现EADDRINUSE原因是系统里还有一个残留进程占着 3000 端口。常见于之前手动node server.js起的进程没被杀干净或者 PM2 的旧进程状态异常。处理步骤先用lsof -i:3000找到占用端口的进程 PID然后用kill -9 PID杀掉。如果是 PM2 自己管理的进程出了问题直接pm2 delete my-app后重新pm2 start ecosystem.config.js --env production比反复 restart 更干净。还有个“进程假死”的情况Node 进程在 PM2 里显示 online但接口请求完全没有响应。原因通常是事件循环被阻塞或者进入了死循环。碰到这种情况先看日志再pm2 restart my-app恢复。如果反复假死就要从代码层面找原因而不是指望 PM2 自动重启解决一切。前端部署这件事做到最后拼的不是命令敲得多熟练而是对线上状态有没有底。我自己现在每次上线前都会做一件很土但很有效的事在本地记录构建产物的 md5发布后用 curl 查看线上文件的 etag确认版本确实切换了才收工。这个习惯帮我挡掉过好几次因为缓存或者同步路径错误导致的“线上还是旧页面”问题。把这套流程跑顺之后你会发现无论以后换到哪家云服务商、换成什么规格的服务器核心逻辑都是一样的真正值钱的不是某一条命令而是你对整个发布链路的状态掌控。