1. 你以为部署很简单,其实坑都在路上
上周五晚上十点半,我坐在电脑前看着阿里云 ECS 控制台里那只运行了六分钟又自动退出的 Node 进程,整个人是崩溃的。日志里只有一行Error: listen EADDRINUSE: address already in use :::3000,而我把服务器上的进程翻了个底朝天,也没找到谁占着 3000 端口。后来才知道,是上一次部署时 PM2 的进程还挂在后台,我kill的只是当前 shell 的会话,而不是真正监听端口的那个进程。
这就是我把一个 NestJS 项目从本地开发环境迁到阿里云 ECS 时遇到的第一道坎。说实话,在这之前我一直觉得部署这事儿挺简单的——把代码传上去,装个 Node.js,npm run start:prod,完事。可真到了生产环境,要处理的问题远比想象中多:ECS 安全组规则、Node.js 版本管理、环境变量配置、反向代理、HTTPS 证书、进程守护、日志切割……每一步都有可能在半夜给你惊喜。
这篇博文不是官方文档的复述,而是我把一个 NestJS 项目完整部署到阿里云 ECS 全过程的踩坑记录。我会把遇到的问题、排查的思路、最终能够稳定跑起来的配置都整理出来,包括那些网上很少有人说清楚的细节。如果你正准备把一个 NestJS 服务部署到云服务器上,或者已经在部署的路上被各种报错折磨,这篇文章应该能帮你少走不少弯路。
先说下我这次的部署环境:阿里云 ECS,2 核 4G 内存,系统是 Ubuntu 22.04,地域华东 1 杭州。项目本身是 NestJS 10 + TypeScript,使用 Prisma 连接 MySQL 8.0,Redis 做缓存,整个服务通过 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 官方源的速度一言难尽,装个依赖等半天是常事。我换成了 npmmirror:
npm 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_email@example.com" cat ~/.ssh/id_rsa.pub把输出的公钥添加到 GitHub 账号的 SSH keys 里。然后测试连接:
ssh -T git@github.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_ENV=production PORT=3000 DATABASE_URL="mysql://username:password@localhost:3306/dbname" REDIS_HOST=127.0.0.1 REDIS_PORT=6379 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 'myapp'@'localhost' IDENTIFIED BY 'strong_password'; CREATE DATABASE myapp_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; GRANT ALL PRIVILEGES ON myapp_db.* TO 'myapp'@'localhost'; 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.conf:
requirepass 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 安全组只放行了 22(SSH)端口。你的 NestJS 应用跑在 3000 端口,外部访问不到是很正常的。需要去阿里云控制台,找到你的实例 → 安全组 → 配置规则,添加入方向规则:
- 端口 80:HTTP 访问
- 端口 443:HTTPS 访问
- 端口 22:SSH(默认已有)
- 端口 3000:如果你暂未使用 Nginx,需要对外暴露 API,需要放行;如果用了 Nginx,3000 端口可以不对公网开放
这里有一个非常关键的安全建议:数据库端口(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 架构,可靠性和扩展性都有保障。
安装 Nginx:
sudo apt install nginx sudo systemctl enable nginx sudo systemctl start nginx安装完成后,本地执行curl http://localhost应该能看到 Nginx 的默认欢迎页。这一步确认 Nginx 没问题。
5.2 配置反向代理,把根路径指向 NestJS
Nginx 的站点配置文件在/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 基本说不过去。浏览器会提示不安全,搜索引擎的权重也会受影响。我用的方案是 Let's 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_URL |
| 502 Bad Gateway | NestJS 进程未启动或崩溃 | 检查 PM2 状态、应用日志 |
| Socket.IO 连不上 | Nginx 未配置 Upgrade 头 | 补齐 WebSocket 相关 proxy_set_header |
| 上传文件提示 413 | Nginx 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 头配了吗?时区改了吗?日志切割装了吗?每一个小问题单拎出来都不难,但它们组合在一起,足以让一个新手折腾两三天。所以我把这些运维细节整理下来,希望能帮到你——踩坑不可怕,可怕的是同样的坑踩了三次还不知道怎么绕过去。