
1. 你以为部署很简单其实坑都在路上上周五晚上十点半我坐在电脑前看着阿里云 ECS 控制台里那只运行了六分钟又自动退出的 Node 进程整个人是崩溃的。日志里只有一行Error: listen EADDRINUSE: address already in use :::3000而我把服务器上的进程翻了个底朝天也没找到谁占着 3000 端口。后来才知道是上一次部署时 PM2 的进程还挂在后台我kill的只是当前 shell 的会话而不是真正监听端口的那个进程。这就是我把一个 NestJS 项目从本地开发环境迁到阿里云 ECS 时遇到的第一道坎。说实话在这之前我一直觉得部署这事儿挺简单的——把代码传上去装个 Node.jsnpm run start:prod完事。可真到了生产环境要处理的问题远比想象中多ECS 安全组规则、Node.js 版本管理、环境变量配置、反向代理、HTTPS 证书、进程守护、日志切割……每一步都有可能在半夜给你惊喜。这篇博文不是官方文档的复述而是我把一个 NestJS 项目完整部署到阿里云 ECS 全过程的踩坑记录。我会把遇到的问题、排查的思路、最终能够稳定跑起来的配置都整理出来包括那些网上很少有人说清楚的细节。如果你正准备把一个 NestJS 服务部署到云服务器上或者已经在部署的路上被各种报错折磨这篇文章应该能帮你少走不少弯路。先说下我这次的部署环境阿里云 ECS2 核 4G 内存系统是 Ubuntu 22.04地域华东 1 杭州。项目本身是 NestJS 10 TypeScript使用 Prisma 连接 MySQL 8.0Redis 做缓存整个服务通过 Nginx 反向代理对外提供 API前端静态文件也由同一台 Nginx 托管。这个配置不算高但应对中小型项目的生产环境完全够用。2. 服务器选型和环境准备这里的选择决定了后面省不省心2.1 ECS 实例选型别在这一步太抠很多人第一步就栽在实例规格上。如果你只是想跑着玩玩那 1 核 2G 的入门实例也能把 NestJS 拉起来但一旦涉及到 MySQL、Redis、Nginx 共存再加上 Node.js 进程本身的内存占用1G 内存是真的会把你逼疯的。我的建议是至少 2 核 4G 起步原因很简单NestJS 应用加上数据库和缓存内存占用轻松超过 1.5G如果还要编译 TypeScript 或者跑 CI内存不够会频繁触发 OOM Killer到时候报错都是莫名的进程被杀排查起来极其痛苦。操作系统我选了 Ubuntu 22.04 LTS。不选 CentOS 的原因很实在Ubuntu 的软件源更新快Node.js 的安装方式多社区资料也多遇到问题搜解决方案的时候命中率高。阿里云的系统盘默认给了 40G如果只是部署一两个应用这个容量差不多够了但建议在创建实例时直接把数据盘加上把数据库的数据目录和应用的日志目录挂到数据盘上这样即使系统盘出问题数据还在。另外有一个很容易忽略的点创建实例时设置的登录密码和你在控制台重置后的密码是两回事。我就遇到过明明记得密码却怎么也登不上去最后才发现是创建时手滑设置错了重置之后才解决。所以创建完实例后第一时间用 SSH 测试登录顺手把 root 用户的 SSH 密钥登录也配上后面部署和排查都会顺畅很多。2.2 Node.js 版本管理用 nvm 而不是直接 apt 安装这是我在实际部署中强烈推荐的方案。直接apt install nodejs装出来的 Node.js 版本往往偏旧而 NestJS 10 对 Node.js 的版本有明确要求——至少 16 以上推荐 18 或者 20。旧版本可能导致某些依赖装不上或者运行时报语法错误。我用的 nvm安装命令一行搞定curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash source ~/.bashrc nvm install 20 nvm alias default 20这里有个细节nvm 安装完成后nvm命令要在新的 shell 窗口里才能直接使用如果source ~/.bashrc之后还是提示找不到命令检查一下~/.nvm/nvm.sh文件是否存在存在的话手动 source 一下就行。npm 的镜像源也建议在部署前换好。国内访问 npm 官方源的速度一言难尽装个依赖等半天是常事。我换成了 npmmirrornpm config set registry https://registry.npmmirror.com这一步不是必需的但能显著缩短你后面构建时等待的时间。2.3 PM2进程守护是生产环境的底线NestJS 应用跑在生产环境绝对不能用裸node main.js的方式因为你不能保证它永远不会崩。PM2 是我用下来最顺手的一个 Node.js 进程管理工具它能做到进程守护、自动重启、日志管理、负载均衡而且配置非常简单。安装 PM2 也有两种方式全局安装或者用 npx 临时调用。建议全局安装因为 PM2 本身也需要一个常驻的守护进程用 npx 方式在自动化部署脚本里受限较大npm install -g pm2PM2 的配置后面单独讲这里先提一句PM2 的主进程崩溃后它自己是不会自动恢复的。所以更保险的做法是用 systemd 给 PM2 本身做个守护。这个网上有一些现成的配置但说实话稳定性要求没那么高的话PM2 默认的行为已经够用了别在这一步过度设计。3. 项目构建与代码部署最容易出问题的是 .env 和环境变量3.1 代码上传方式Git 仓库拉取优于本地传文件代码怎么到服务器上不同人有不同习惯。我之前图省事用过scp直接把整个项目目录传上去结果因为 node_modules 的存在传了半天而且本地和生产环境的依赖版本很容易出现不一致。后来老老实实改用 Git 仓库拉取的方式本地代码 push 到 Git 仓库GitHub 私有仓库、Gitee 都行服务器上git pull拉下来干净利落。如果你是个人项目用 GitHub 私有仓库就够了。服务器上需要配置 SSH key 才能免密拉取配置方法ssh-keygen -t rsa -b 4096 -C your_emailexample.com cat ~/.ssh/id_rsa.pub把输出的公钥添加到 GitHub 账号的 SSH keys 里。然后测试连接ssh -T gitgithub.com能输出 Hi 开头的欢迎语就说明配置成功了。代码拉下来的位置我习惯放在/var/www/或者/home/deploy/apps/。不建议放 root 目录下也不建议直接用 root 用户跑应用生产环境用普通用户跑应用是基本要求安全性和权限管理都会更清晰。我通常的做法是创建一个专门的 deplooy 用户给予它代码目录的读写权限sudo useradd -m deploy sudo mkdir -p /var/www/myapp sudo chown -R deploy:deploy /var/www/myapp3.2 依赖安装与构建流程锁定版本避免神经刀依赖安装我用的 npm ci 而不是 npm install区别在于 npm ci 会严格按照 package-lock.json 的版本安装不会去解析和升级依赖安装速度快而且能保证生产环境和本地开发环境的依赖版本完全一致。如果你之前遇到过“本地跑得好好的上服务器就报错”的问题大概率就是依赖版本不一致导致的换个 npm ci 能解决一大部分。cd /var/www/myapp npm ci npm run buildNestJS 默认的 build 脚本会输出到 dist 目录。构建过程中容易遇到的坑是 Prisma 这类有原生依赖的库。如果你的项目用了 Prisma构建之前要执行一次npx prisma generate否则运行时会报PrismaClient is not configured to run in Vercel或者更常见的Query engine library for current platform debian-openssl-3.0.x could not be found。Prisma 会下载对应平台的查询引擎不同系统的引擎文件不通用所以本地生成的 node_modules 拷贝到服务器上肯定跑不起来。这也是为什么我推荐 Git 仓库拉取 npm ci 重新安装的方式而不是直接拷贝本地目录。3.3 .env 环境变量这个文件千万别传进 Git 仓库环境变量是部署中最容易出问题、也最容易出安全问题的环节。.env 文件绝对不能提交到 Git 仓库.gitignore 里一定要加上。生产环境的环境变量应该直接手动创建在服务器上或者用 Secret Manager 之类的工具管理。我踩过的坑是本地 .env 里的数据库连接串是 localhost 和本机密码到了服务器上忘记修改导致应用启动时报数据库连接失败。这个报错很容易让人误以为是 MySQL 没装好其实只是 .env 里还是旧内容。生产环境的 .env 至少要包含这些变量NODE_ENVproduction PORT3000 DATABASE_URLmysql://username:passwordlocalhost:3306/dbname REDIS_HOST127.0.0.1 REDIS_PORT6379 JWT_SECRET你的随机长字符串注意 DATABASE_URL 里的密码如果包含特殊字符需要做 URL 编码。比如密码里有个 那就要写成%40。这个坑我也踩过排查半天最后发现是连接串解析错了。环境变量修改后要重启应用才能生效。PM2 的重启是pm2 restart all但这只是重启 Node 进程如果你的环境变量是通过 systemd 注入的方式会不同。我的习惯是用 PM2 的 ecosystem 配置文件来管理环境变量这样 PM2 启动时会把配置注入到进程环境里部署脚本也更统一// ecosystem.config.js module.exports { apps: [{ name: myapp, script: dist/main.js, instances: 2, exec_mode: cluster, env: { NODE_ENV: production, PORT: 3000 }, env_production: { NODE_ENV: production } }] };启动时指定环境pm2 start ecosystem.config.js --env production。4. 数据库和中间件部署坑一个个来别慌4.1 MySQL 8.0 部署与远程连接配置如果 ECS 实例的内存是 4G装 MySQL 8.0 问题不大但要注意配置文件的调整。默认的 MySQL 配置是为了通用场景设计的4G 内存的机器上来就跑默认配置很快会出现内存告警。MySQL 8.0 的安装比较直接sudo apt update sudo apt install mysql-server sudo systemctl enable mysql sudo systemctl start mysql安装完成后默认 root 用户只能通过本地 socket 连接密码是空的。你需要先登录进去创建一个专门的应用账号并且确认账号的 host 设置是正确的。比如如果你的应用和数据库在同一台机器上host 设为 localhost 就行CREATE USER myapplocalhost IDENTIFIED BY strong_password; CREATE DATABASE myapp_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; GRANT ALL PRIVILEGES ON myapp_db.* TO myapplocalhost; FLUSH PRIVILEGES;数据库的字符集一定要用 utf8mb4 而不是 utf8否则你在开发时存入的 emoji 表情比如用户昵称带了 在生产环境会变成乱码甚至报错。这个细节在开发时注意不到等上了生产才暴露。我用的 Prisma所以在 MySQL 装好之后需要先执行迁移把表结构建好npx prisma migrate deploy这里跟本地开发时npx prisma migrate dev不一样生产环境用 deploy它只会执行未执行过的迁移文件不会做交互式的确认。4.2 Redis 安装与保护别忘了设置密码Redis 的安装很简单sudo apt install redis-server安装好之后默认配置下 Redis 只监听 127.0.0.1这是安全的但如果你想通过公网访问比如本地调试的时候就必须设置密码。我的建议是生产环境 Redis 只监听 127.0.0.1应用和 Redis 通过内网通信不要把 Redis 暴露到公网。如果实在需要外部访问比如你本地开发连服务器的 Redis那就设置 requirepass并且只让它监听特定 IP。改配置文件/etc/redis/redis.confrequirepass your_redis_password bind 127.0.0.1改完重启sudo systemctl restart redis。然后测试redis-cli -a your_redis_password PING能返回 PONG 就说明连接正常。这个密码要和 NestJS 应用里的 REDIS_HOST、REDIS_PORT 配置对上。4.3 安全组这个不配端口全废阿里云 ECS 的安全组相当于服务器外层的防火墙。很多新手部署完服务发现外部怎么都访问不到不是应用没启动而是安全组没放行对应端口。默认情况下ECS 安全组只放行了 22SSH端口。你的 NestJS 应用跑在 3000 端口外部访问不到是很正常的。需要去阿里云控制台找到你的实例 → 安全组 → 配置规则添加入方向规则端口 80HTTP 访问端口 443HTTPS 访问端口 22SSH默认已有端口 3000如果你暂未使用 Nginx需要对外暴露 API需要放行如果用了 Nginx3000 端口可以不对公网开放这里有一个非常关键的安全建议数据库端口3306、Redis 端口6379不要对公网开放。你只需要通过 SSH 登录服务器在服务器本机连接数据库和 Redis 就行。放到公网上就是明着让人来扫端口爆破不要给自己挖坑。如果确实需要远程管理数据库用 SSH 隧道的方式就够了。5. Nginx 反向代理和 HTTPS正式上线前必须做的事5.1 为什么需要 Nginx 反向代理直接用 3000 端口对外提供服务不是不行但有几个问题很难绕开HTTPS 证书的配置和续签直接挂在 Node 进程上会很复杂Node 进程一旦重启端口就断了Nginx 可以做到负载均衡和请求缓冲Nginx 处理静态文件和高并发静态请求的效率远超 Node日志可以统一在 Nginx 层管理比在应用层处理更规范所以我的方案是Nginx 监听 80 和 443把所有 API 请求反向代理到 127.0.0.1:3000 上的 NestJS 应用。这种经典的 Web 架构可靠性和扩展性都有保障。安装 Nginxsudo apt install nginx sudo systemctl enable nginx sudo systemctl start nginx安装完成后本地执行curl http://localhost应该能看到 Nginx 的默认欢迎页。这一步确认 Nginx 没问题。5.2 配置反向代理把根路径指向 NestJSNginx 的站点配置文件在/etc/nginx/sites-available/默认的默认站点在/etc/nginx/sites-enabled/default。我建议不使用默认配置而是新建一个站点配置sudo nano /etc/nginx/sites-available/myapp配置内容server { listen 80; server_name your_domain.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_cache_bypass $http_upgrade; 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 Upgrade $http_upgrade和Connection upgrade这两行是为 WebSocket 准备的。如果你的 NestJS 项目用了 Socket.IO 或者 GraphQL Subscription这两行是必须的否则 WebSocket 连接会一直握手失败。X-Real-IP和X-Forwarded-For用于把客户端的真实 IP 传给后端。NestJS 里如果用了request.ip或者自定义的 IP 获取逻辑这两个头不能少否则拿到的都是 127.0.0.1日志里的用户 IP 全废了。X-Forwarded-Proto用于告诉后端请求是 HTTP 还是 HTTPS。NestJS 里启用 HTTPS 重定向或者生成绝对 URL 时需要用到。启用配置sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginxnginx -t是检查配置文件语法是不是正确。之前遇到过配置写错直接导致 Nginx 无法启动的情况所以每次改完配置先执行一下这个测试命令。5.3 HTTPS 证书与自动续期免费也能很好用现在做网站没有 HTTPS 基本说不过去。浏览器会提示不安全搜索引擎的权重也会受影响。我用的方案是 Lets Encrypt 的免费证书加上 certbot 的自动续期。sudo apt install certbot python3-certbot-nginx sudo certbot --nginx -d your_domain.comcertbot 会自动获取证书并且自动修改 Nginx 配置把 80 端口的请求重定向到 443。整个过程大概两三分钟非常省心。证书没多久到期一次需要续期。certbot 的续期命令sudo certbot renew --dry-run这个命令只是测试续期不会真正执行。测试通过后可以把它加入 crontab 实现自动续期crontab -e添加一行0 3 * * * /usr/bin/certbot renew --quiet每天凌晨三点检查一次证书快到期时自动续期续完自动重载 Nginx。配上之后证书这块基本不用再操心了。有个细节要注意如果你在阿里云控制台同时又申请了阿里云的免费 SSL 证书有效期一年这两者的逻辑会冲突。建议二选一不要同时用否则后续排查证书问题时分不清是哪个证书生效。6. 常见问题与象查技巧实录这些都是我踩过的坑6.1 问题速查表现象可能原因解决方案外部访问不到应用安全组未放行对应端口检查 ECS 安全组入方向规则应用启动即退出无报错端口被占用lsof -i:3000查看占用进程PrismaClient 引擎报错生产环境未执行 prisma generate构建后执行npx prisma generate数据库连不上的报错.env 中连接串仍是本地配置检查 .env 的 DATABASE_URL502 Bad GatewayNestJS 进程未启动或崩溃检查 PM2 状态、应用日志Socket.IO 连不上Nginx 未配置 Upgrade 头补齐 WebSocket 相关 proxy_set_header上传文件提示 413Nginx body size 限制添加client_max_body_size 10m;静态资源 404前端静态文件路径不对确认 Nginx 的 root 指向 dist 目录内存经常爆满PM2 启动实例数过多降低 instances 数量或升级配置证书续期失败域名解析未指向本机确认 DNS 解析正常6.2 排查思路顺着链路一层层查碰到问题第一反应不是谷歌而是按链路排查客户端 → 安全组 → Nginx → 应用进程 → 数据库/缓存。每一步都有一个快速验证的方法本地访问curl https://your_domain.com看返回什么如果是超时问题大概率在安全组如果是 Nginx 的 502说明 Nginx 正常问题在应用层。sudo tail -f /var/log/nginx/error.log看 Nginx 的报错信息能帮你判断是不是后端进程挂了。pm2 logs看 NestJS 应用的日志这里能看到应用层的报错比如数据库连接失败、未捕获的异常等。如果应用报了数据库连接超时检查 MySQL 是否在运行systemctl status mysql然后用应用账号手动连接一次测试。这套链路排查的方法能帮你快速定位问题在哪一层不用盲目试。6.3 日志管理不然半年后出问题无从查起很多人部署完服务就不管日志了这是不对的。生产环境的日志就是案发现场的证据出问题时全靠它还原现场。我常用的方式是 PM2 自带的日志加上 Nginx 的 access.log。PM2 会把 stdout 和 stderr 分流写日志位置默认在~/.pm2/logs/或者你通过 ecosystem.config.js 指定的位置。问题是我的项目里如果不做任何处理NestJS 内置的 Logger 打的日志是输出到 stdout 的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 份过期的压缩保存。配上之后就不用再担心日志文件把磁盘塞满了。6.4 性能监控看看你的 CPU 和内存哪里去了生产环境跑了一段时间后我习惯隔一段时间上去看一眼资源占用情况。阿里云控制台自带的监控能看到基础指标但更细粒度的信息还是从服务器上看更直接。CPU 和内存的实时状态用top或者htop磁盘占用用df -h每个进程的资源占用用ps aux --sort-%mem。这几个命令组合起来基本能把服务器的情况摸透。如果发现 CPU 持续飙高先用pm2 list看看是不是应用进程数量太多或者某个接口的请求量异常。如果发现内存一直涨、涨到一定程度被 OOM说明代码里有内存泄漏可以通过持续监控pm2 monit观察进程的内存趋势来佐证。Node.js 应用的内存泄漏排查是另一个话题但至少从部署层面要确保你有手段发现这个问题。6.5 遇到过的几个冷门坑真的能让人怀疑人生第一个坑是时区问题。服务器默认时区是 UTC而项目业务里如果用到了日期计算、定时任务很容易出现时间偏差。比如定时任务原定每天 8 点执行结果实际是 16 点执行东八区比 UTC 快 8 小时。解决方式sudo timedatectl set-timezone Asia/Shanghai然后date确认时区已改。顺手把 MySQL 的时区也确认一下避免两者时间戳对不上。第二个坑是上传文件大小限制。NestJS 默认能处理的请求体大小有限Nginx 层默认也只有 1MB。如果你在后台上传超过 1MB 的图片或文件会莫名收到 413 Request Entity Too Large。解决方式Nginx 配置里加client_max_body_size 10m;应用层用bodyParser.json({ limit: 10mb })调整限制。两层的限制都要调只改一层效果不够。第三个坑是文件权限问题。如果你把代码上传到/var/www/myapp后发现 PM2 无法写入日志文件或临时文件往往是目录属主不对。解决方式sudo chown -R deploy:deploy /var/www/myapp然后确保启动应用的用户对目录有写权限。7. 后续还能做什么把部署这件事做得更漂亮7.1 用 Docker 封装部署环境一致性一劳永逸如果你觉得每次部署都要在服务器上装 Node.js、装数据库、装 Redis 太麻烦或者说你部署的目标环境不止一个Docker 会是更好的选择。把 NestJS 应用镜像化之后任何一台装了 Docker 的服务器拉下来就能跑不用担心 Node 版本不一致、依赖缺失等问题。但 Docker 也不是银弹。镜像构建时如果处理不当镜像会非常大容器的日志、数据持久化如果不规划好容器一删数据全没。我是把单机部署踩熟之后才逐步迁移到 Docker Compose 的建议你也先熟悉裸机部署的方式再考虑容器化这样出问题时你还能去理解容器内部的运行逻辑。7.2 部署脚本自动化一键发布不再手忙脚乱如果项目迭代频繁每次手动git pull、npm ci、npm run build、pm2 restart这套流程重复十几遍之后你一定会想写个脚本把这些操作串起来。现在我的方式是服务器上放一个 deploy.sh执行一次脚本完成从拉代码到重启应用的全流程#!/bin/bash cd /var/www/myapp git pull origin main npm ci npm run build npx prisma migrate deploy pm2 reload ecosystem.config.js --env production脚本再配合 Git 的 hook每次代码 push 到主干分支之后自动执行就实现了一个非常轻量的 CI/CD 流程。Jenkins、GitLab CI 这些工具当然更强大但对个人项目和中小团队来说一个 shell 脚本加上 cron 或者 webhook 可能已经够用且更好维护。7.3 多环境管理别再把测试环境和生产环境混在一起部署到生产之前我强烈建议先有一个测试环境。我之前犯过的错误是改完代码直接推到主干然后部署到生产结果有 bug 直接暴露给用户体验很糟糕。后来我在同一台服务器上用 Nginx 的 server_name 区分了 test 和 prod 两个站点用不同的端口跑两套 NestJS 实例用不同的 .env 文件管理配置才算勉强有了一个环境隔离的方案。更规范的做法是准备两台服务器一套测试一套生产但成本就上去了。具体看自己的预算和项目至少要做到配置隔离。我在实际操作中最大的体会是部署这件事本质上拼的是细节管理能力。数据库连接串写对了吗安全组放行了吗Nginx 的 WebSocket 头配了吗时区改了吗日志切割装了吗每一个小问题单拎出来都不难但它们组合在一起足以让一个新手折腾两三天。所以我把这些运维细节整理下来希望能帮到你——踩坑不可怕可怕的是同样的坑踩了三次还不知道怎么绕过去。