Docker + Nginx 反向代理 Node.js 应用:从部署到避坑全指南

Docker + Nginx 反向代理 Node.js 应用:从部署到避坑全指南 最近把一个 Node.js 项目收尾时被一个问题搞得很尴尬项目经理打开浏览器测试连着问了我三句你到底开了几个端口。3000 是 API8080 是管理后台3001 还挂着一个 WebSocket 推送服务。没有统一入口没有域名全靠IP:端口访问前端联调时要背一串数字我自己都嫌烦。后来我花了半天时间把这套 Node.js 服务装进 Docker再用 Nginx 做反向代理统一收口——外部请求只走 80 端口内部服务各自安好。整个过程不算复杂但里面藏了不少细节镜像怎么拉得快、容器之间怎么通信、proxy_pass后面到底要不要加斜杠、502 和 404 分别代表哪里出了问题。这些坑文档里往往一句话带过实战中却能卡你一小时。这篇文章就把完整搭建过程拆开讲一遍从 Docker 环境准备、Node.js 镜像构建到 Nginx 反向代理配置、Docker Compose 一键编排最后是几个我亲测遇到的坑和排查链路。适合刚接触 Docker、又恰好想用 Nginx 把 Node.js 服务暴露到外网的朋友照着抄作业基本能一次跑通。1. 为什么是 Docker Nginx Node.js这套组合解决的真实问题先说清楚三个角色各干什么不然你很可能把 Nginx 装进 Docker 里又把 Node.js 直接跑在宿主机上最后容器内外互通问题搞得一头雾水。1.1 三个组件在整套架构里的分工Docker负责统一运行环境。本地开发是 Windows/Mac服务器是 Ubuntu但容器里跑的永远是同一个 Linux 内核环境不存在我本机能跑服务器上就跑不起来的玄学。Node.js负责实际业务逻辑监听一个内部端口比如 3000处理/api接口、WebSocket 等动态请求。Nginx作为流量入口对外只暴露 80/443 端口。收到请求后按规则转发给 Node.js 容器还能顺手托管静态资源、做请求日志、加 HTTP 基础安全头。一个容易忽略的事实是如果你的站点只有 Node.js没有静态资源也不打算开 HTTPS其实不需要 Nginx。但一旦涉及多服务、HTTPS 证书、多域名、或想用同一台服务器跑好几个项目Nginx 的价值立刻体现——你不需要在每个 Node.js 服务里各自处理证书和端口监听统一交给 Nginx 一层搞定。1.2 为什么用反向代理而不是直接暴露 Node.js 端口有人会问Node.js 自己也能监听端口为什么非要套一层 Nginx直接暴露 Node.js 端口不是不行但有几个实际问题端口资源混乱一个服务占一个端口服务多了之后运维要想半天18888 是哪个项目的。HTTPS 配置分散每个 Node.js 项目都要配一遍证书浪费精力还容易漏配。静态文件处理效率Nginx 处理静态文件的能力远强于 Node.js直接让 Nginx 返回图片、CSS、JSNode.js 只处理 API响应速度有明显提升。灵活切换上游升级 Node.js 服务时Nginx 可以平滑切换流量也可以轻松在多个 Node.js 实例之间做负载均衡。反向代理的角色用一句话概括就是所有请求先到 NginxNginx 按照规则决定交给哪个上游服务。架构上前端只认识一个入口后端服务各自解耦。1.3 这套组合适合什么场景个人博客或中小型网站Node.js 做服务端渲染Nginx 统一入口。前后端分离项目前端静态文件交给 Nginx后端 API 反向代理到 Node.js。一台服务器上跑多个 Node.js 应用或 Node.js MySQL Redis 等中间件用 Docker Compose 一键管理。如果你属于以上任意一种接下来这套流程可以直接照搬。2. 环境准备Docker 安装与镜像源配置90% 新手卡在前两步我见过不少人在 Dockerfile 里花了两个小时最后发现 docker pull 一个基础镜像就卡了二十分钟——这不是网络问题而是镜像源配置没做。所以环境准备阶段我通常把镜像加速放在第一步。2.1 各平台安装 Docker 的差异Windows 用户装 Docker Desktop这个没太大争议。安装时注意勾选使用 WSL 2 后端比传统的 Hyper-V 模式更省资源文件挂载性能也更好。装完以后打开 Settings → General确认Use the WSL 2 based engine是开启状态。WSL 2 需要系统里先有一个 Linux 发行版比如 Ubuntu这个在 PowerShell 里执行wsl --install就能装上然后重启一次系统。Ubuntu 服务器用户我习惯直接用官方 apt 源安装但如果你服务器在国内建议先把 apt 源换成镜像源再执行sudo apt update sudo apt install -y docker.io sudo systemctl enable --now docker sudo usermod -aG docker $USER最后一步usermod是为了让当前用户免 sudo 执行 docker 命令。注意执行完以后要退出终端重新登录才生效。装好以后验证docker --version docker compose version能看到版本号就说明基础环境没问题。2.2 镜像下载慢的根治方案配置 registry-mirrorsDocker 默认从 Docker Hub 拉取镜像国内直连速度非常不稳定经常出现下载到一半卡住的情况。解决方案是给 Docker 配置一个镜像加速器地址。Linux 上编辑/etc/docker/daemon.json{ registry-mirrors: [ https://docker.example.com ] }这里不写具体地址了各家云厂商都提供镜像加速服务建议用你自己服务器所在云服务商的控制台里给出的那个加速地址稳定性最有保障。配置完重启sudo systemctl restart dockerWindows Docker Desktop 用户在 Settings → Docker Engine 里修改同样的 JSON 配置即可注意修改后点Apply Restart。验证加速是否生效docker info在输出里找Registry Mirrors一节能看到你配置的地址就说明生效了。提示就算配了镜像加速某些非常大的镜像比如完整的 Node 开发镜像第一次拉取也要几分钟这是正常的不要一慢就 CtrlC。2.3 一个顺手的小检查确认 Docker 守护进程正常配置完以后先拉一个最小的镜像做连通性测试docker pull hello-world docker run --rm hello-world能打印出 Hello from Docker! 就表示整个 Docker 链路守护进程、镜像仓库、容器运行时是通的。如果这里就卡住先回头检查 daemon.json 格式是不是有问题JSON 解析失败会导致 docker 服务起不来。3. 用 Dockerfile 把 Node.js 应用容器化从零到可访问环境就绪后第一步先把 Node.js 服务装进容器确保它能在容器里独立跑起来。这个阶段我会先不碰 Nginx方便后面排错时明确问题边界如果 Node.js 容器本身都不通Nginx 配得再花哨也没用。3.1 准备一个最小可用的 Node.js 项目为了演示我建一个简单的 Express 应用路径假设是/appconst express require(express); const app express(); app.get(/, (req, res) { res.send(Hello from Node.js in Docker!); }); app.get(/api/users, (req, res) { res.json([ { id: 1, name: Alice }, { id: 2, name: Bob } ]); }); app.listen(3000, () { console.log(Node.js server is listening on port 3000); });对应的package.json{ name: node-docker-demo, version: 1.0.0, main: app.js, scripts: { start: node app.js }, dependencies: { express: ^4.19.2 } }这个服务逻辑很简单根路径和一个/api/users接口。后面用 Nginx 反代时/api/users能否正常转发就是验证链路通不通的关键指标。3.2 编写 Dockerfile为什么这几个细节值得注意FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install --registryhttps://registry.npmmirror.com COPY . . EXPOSE 3000 CMD [node, app.js]新手往往直接COPY . .然后RUN npm install看起来没什么问题但这几条指令的顺序是有讲究的COPY package*.json ./和RUN npm install放在业务代码复制之前Docker 构建是有缓存的只要package.json没变npm install 这一层就不会重新执行。如果你先把整个目录复制进去再 install那么业务代码任何一行改动都会导致 npm install 重跑一遍浪费时间。node:20-alpine而不是node:20alpine 镜像体积只有普通镜像的一半甚至更少带基础编译工具链也够用。如果你的 npm 依赖里有需要编译原生模块的包alpine 上可能要找对应编译依赖真碰上了再换成 slim 版本也不迟。对演示项目来说 alpine 完全够。npm install里加了--registry参数国内执行 npm install 时经常卡在下载依赖这一步显式指定 npm 镜像源能大幅提速。如果你在.npmrc里已经配置过这里可以省略。.dockerignore也要建一个避免把node_modules和本地日志等无用的东西送进构建上下文node_modules npm-debug.log .git .env3.3 构建镜像并启动容器验证cd /app docker build -t node-demo .构建成功后启动docker run -d --name node-app -p 3000:3000 node-demo-d表示后台运行--name给容器起名字-p 3000:3000把容器的 3000 端口映射到宿主机的 3000 端口。验证curl http://localhost:3000/api/users正常会看到 JSON 数据。如果 Windows PowerShell 里没有 curl用浏览器打开http://localhost:3000/api/users也可以。看到输出后说明 Node.js 容器本身工作正常。这里有个小习惯我建议养成每次验证容器服务至少执行一次docker logs node-app看看进程启动日志里有没有报错很多问题就是启动时依赖缺失导致进程反复重启而不是服务本身没起来。4. Nginx 反向代理的核心配置逐行拆开讲Node.js 容器跑通之后现在把大门口打开——让 Nginx 做入口转发请求给 Node.js。这阶段我会先把 Nginx 也用 Docker 装起来因为后面要跟 Node.js 容器组成同一个 Docker 网络这样才能用服务名互相访问。4.1 反向代理和正向代理别一上来就混淆很多教程默认读者已经懂代理但实际不少人是第一次碰这个词。我做一个最直白的类比正向代理客户端知道他访问的不是目标服务器。比如你通过某台中间服务器访问一个网站对于最终网站来说请求来自中间服务器。代理站在客户端这边。反向代理客户端只知道自己访问的是 Nginx完全不知道背后还有 Node.js 服务器。代理站在服务端这边对外暴露的是一个统一入口。Nginx 在整套架构里就是典型的反向代理角色浏览器请求http://服务器IP/Nginx 收到以后偷偷把请求转交给http://node-app:3000/Node.js 处理完返回数据再由 Nginx 原样返回给浏览器。浏览器全程感知不到 Node.js 的存在。4.2 新建 Nginx 配置文件挂载方式决定扩展性不直接改 Nginx 镜像里的默认配置而是把配置写在宿主机上用挂载的方式替换容器内的配置文件。这样以后改配置不用重新构建镜像只需要改文件、重载容器即可。我习惯创建一个nginx/nginx.conf文件下面是完整内容用conf.d方式覆盖默认站点配置server { listen 80; server_name _; location / { proxy_pass http://node-app: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_set_header X-Forwarded-Proto $scheme; } location /api/ { proxy_pass http://node-app: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_set_header X-Forwarded-Proto $scheme; } }如果你先单独测 Nginx不着急上 Compose可以先手动把 Nginx 跑起来docker run -d --name nginx-proxy \ -p 80:80 \ -v $(pwd)/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro \ --network 容器网络名 \ nginx:1.25-alpine但先别急这里有个前置条件Nginx 容器里写的是proxy_pass http://node-app:3000node-app不是 IP 而是容器名。Docker 里的容器名不是天然就能互相解析的必须处于同一个 Docker 网络中。所以更顺滑的做法是直接看下一章的 Docker Compose用 Compose 一次性把两个服务放进同一个网络。如果你现在就想手动玩可以用docker network create app-net创建网络然后分别在docker run里加--network app-net参数。4.3 proxy_pass 的两种写法决定了路径会不会被拼接location /api/这个块是新手最常踩坑的地方。proxy_pass后面带不带斜杠结果完全不一样写法效果实际请求到上游proxy_pass http://node-app:3000;保留原始 URI 完整路径/api/users→/api/usersproxy_pass http://node-app:3000/;用斜杠替换 location 前缀/api/users→/users演示项目里 Node.js 的接口路径本身就带/api前缀所以用第一种写法即可保留完整路径Node.js 收到的还是/api/users。如果你在 Node.js 里注册的路由是不带/api前缀的而 Nginx 想对外暴露/api就需要用第二种写法让 Nginx 在转发时把/api前缀去掉。4.4 三个 proxy_set_header 参数复制进去就对了proxy_set_header这几个参数不是可有可无的它们解决的是上游服务如何感知客户端真实信息的问题Host $host把浏览器请求的域名传给 Node.js。如果不传Node.js 里通过req.headers.host拿到的会是node-app:3000某些依赖域名生成链接的逻辑就会出错。X-Real-IP $remote_addr把真实客户端 IP 传给上游。否则 Node.js 拿到的 IP 全是 Nginx 容器的内网 IP。X-Forwarded-For记录每一级代理的 IP 链路多级代理排错时很有用。X-Forwarded-Proto $scheme告诉 Node.js 客户端用的是 http 还是 https。如果你们上了 HTTPS这一步尤其重要Node.js 里判断req.protocol就靠它。这几个头其实是反向代理的标准操作建议无脑复制除非你明确知道自己在做什么。4.5 静态资源直接交给 NginxNode.js 就专心写接口如果前端页面是纯静态文件没必要让 Node.js 来返回 HTML、CSS、JS。Nginx 处理这类请求比 Node.js 快得多。在server块里加一个 locationlocation /static/ { alias /usr/share/nginx/html/; expires 7d; add_header Cache-Control public, max-age604800; }把静态文件目录挂载到容器的/usr/share/nginx/html/请求/static/xxx时由 Nginx 直接读文件返回并设置 7 天缓存。Node.js 容器只处理动态 API两边各干各的。提示alias会把 URL 里的/static/直接映射到目录路径比如/static/app.js对应容器内/usr/share/nginx/html/app.js。如果你用的是root而不是alias路径映射规则会变成两者拼接容易 404。5. 用 Docker Compose 把 Node.js 和 Nginx 串起来网络与端口是重点上面单独跑 Nginx 时最麻烦的是要把 Nginx 容器加入 Node.js 容器所处的网络。手动docker network create也能建但每创建一个服务都手动指定网络服务一多立刻乱套。Docker Compose 就是干这个的一个 YAML 文件定义所有服务、网络、挂载关系一条命令全部启动。5.1 最终的 docker-compose.yml在项目根目录创建docker-compose.ymlservices: node-app: build: . container_name: node-app restart: always expose: - 3000 networks: - app-net environment: - NODE_ENVproduction nginx: image: nginx:1.25-alpine container_name: nginx-proxy restart: always ports: - 80:80 volumes: - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - node-app networks: - app-net networks: app-net: driver: bridge注意几个细节expose和ports的区别node-app用expose表示只允许容器网络内部访问 3000 端口宿主机无法直接访问nginx用ports把 80 端口映射到宿主机外部请求才能进来。不要把 3000 也用ports暴露给宿主机否则就失去了统一入口的意义。depends_on的顺序问题它只能保证 Nginx 容器在 Node.js 容器之后启动并不能保证 Node.js 已经对外提供服务。好在 Node.js 启动通常很快实际影响不大。如果真有启动竞争的问题需要在应用层做健康检查那是后话。container_name的作用在同一个 Compose 网络里容器之间可以用服务名node-app互相访问所以 Nginx 配置里写http://node-app:3000能直接解析不需要查 IP。5.2 启动前的配置校验先别直接up我用一条命令检查 Nginx 配置文件语法docker compose exec nginx nginx -t但此时依赖的服务都还没有启动这条命令会报错。更稳妥的做法是先把服务拉起来docker compose up -d --build然后检查各服务状态docker compose ps看到两个服务都是Up状态再做下一步验证。5.3 手动深入容器内部验证整条链路整条链路通不通不能只看浏览器能不能打开。我会按下面几步排查每步都确认了问题范围就缩小了第一步检查 Nginx 容器能否访问 Node.js 容器docker exec -it nginx-proxy wget -qO- http://node-app:3000/api/usersNginx 镜像通常自带 wget 或 curl任意一个能用就行。如果能返回 JSON说明容器间网络通信正常。第二步检查宿主机通过公网入口访问curl -i http://localhost/api/users看响应头里有没有Server: nginx/1.25.0以及响应体是否为 Node.js 返回的 JSON。到这一步通了说明 80 端口映射和 Nginx 转发都正常。第三步用日志确认请求确实经过了 Nginxdocker logs nginx-proxy能看到访问日志里有/api/users的记录说明外部请求确实先进了 Nginx 再转发。以后排查问题这个日志是第一个要看的证据。5.4 容器网络通信的本质DNS 解析和服务发现为什么配置里写node-app而不是某个具体 IP因为 Compose 创建网络时会自动在网络内启动一个内置 DNS 服务每个容器以它的服务名注册。这个设计带来的好处是如果 Node.js 容器因为某种原因被重建IP 变了但服务名不变Nginx 依然能通过node-app找到新的容器实例。这也是我强烈建议不要在 Nginx 配置里写127.0.0.1:3000或localhost:3000的原因。在容器里localhost指的是容器自己而不是宿主机。写成localhost会让 Nginx 尝试连接自己必然 502。容器间通信只认服务名或具体 IP。6. 我踩过的坑502、404、403、挂载失效排查链路逐条说最后这部分是我觉得最值钱的。Docker Nginx Node.js 这套组合官方文档不会教你的几个坑我全部踩过每个都花了不少时间。6.1 502 Bad Gateway先查容器通不通再查配置现象浏览器访问 Nginx 入口时返回 502 Bad Gateway。排查链路第一步确认 Node.js 容器还活着docker ps如果 Node.js 容器不在列表里或者 STATUS 一栏有Restarting说明进程崩溃了。看日志docker logs node-app常见崩因是 npm 依赖没装全或者代码里监听端口和 Dockerfile 里EXPOSE不一致。第二步确认 Nginx 容器能否解析并访问node-appdocker exec -it nginx-proxy wget -qO- http://node-app:3000/如果这一步返回连接失败最可能的原因是两个容器不在同一个自定义网络里。检查docker inspect nginx-proxy --format {{json .NetworkSettings.Networks}} docker inspect node-app --format {{json .NetworkSettings.Networks}}两边网络名字不一致就把其中一个容器重新加入另一个网络或者干脆用 Compose 统一管理。我之前手动启动过 Nginx后来把 Node.js 容器删了重建新容器网络变了Nginx 里缓存的 DNS 指向了旧的 IP结果 502 了好久才发现。6.2 404 Not Found八成是 proxy_pass 的路径问题现象浏览器访问根路径/正常访问/api/users返回 404。排查链路Node.js 服务直接暴露时用curl http://localhost:3000/api/users是正常的那就说明问题出在 Nginx 转发路径上。检查nginx.conf的location /api/块。我遇到过最隐蔽的情况是写成了location /api { proxy_pass http://node-app:3000; }location /api不带尾部斜杠会同时匹配/api和/api/users而location /api/带尾部斜杠只匹配/api/开头的请求。两者匹配优先级不同行为也有差异。规范做法是想要精确匹配就写location /api想要前缀匹配就写location /api/。6.3 403 Forbidden权限问题不是转发问题现象访问根路径是 403 Forbidden。排查链路403 和 502、404 性质不同它不是连接失败而是 Nginx 有权限拒绝访问。最常见原因是你把静态文件目录挂载到容器里但容器内用户对文件没有读权限。比如docker-compose.yml里写了volumes: - ./static:/usr/share/nginx/html:ro而宿主机上./static/index.html是 root 所有、权限 600。容器内的 nginx 进程通常以nginx用户运行读不了这个文件就返回 403。解决办法是给文件夹加可读权限chmod -R 755 static或者确保文件属主和容器用户匹配。另一个隐蔽场景是宿主机挂载目录里根本没有 index 文件Nginx 默认配置下如果没开目录列表访问/也会 403。先确认挂载目录里到底有哪些文件docker exec -it nginx-proxy ls -l /usr/share/nginx/html/6.4 Windows 下挂载文件不生效先检查 WSL2 路径现象在 Windows 上改了nginx.conf但容器里看到的还是旧配置或者docker compose up -d时提示挂载路径不存在。原因Docker Desktop 在 WSL2 模式下Windows 路径和 WSL 路径的映射关系和我们直觉不一样。C:\Users\xxx\project在 WSL 里可能是/mnt/c/Users/xxx/project但 Compose 文件里写的./nginx/nginx.conf是相对于项目目录解析的。解决办法确保你在正确的项目目录下执行docker compose路径用相对路径./nginx/nginx.conf不要用绝对路径更不要在 Compose 里手写C:/xxx这种 Windows 风格路径。如果发现改了配置不生效执行docker compose down docker compose up -d注意不要用docker compose restart nginx因为某些挂载的配置改动会在重建容器时才重新加载。6.5 镜像拉取卡在中间换加速地址、清理缓存现象docker compose up -d执行后拉取nginx:1.25-alpine或node:20-alpine时下载进度长期停在某个百分比。排查链路这通常不是网断了而是镜像仓库连接不稳定。第一步先确认 Docker 服务是否配置了镜像加速器前面 2.2 已讲过。如果已经配置了还是慢可以换个加速地址不同服务商的镜像同步速度存在差异。另外有个容易忽略的习惯不要在拉镜像的同时跑一堆其他大流量任务尤其不要在下载镜像时反复执行docker build让缓存占满磁盘。清理一下docker system prune另外提一句如果因为网络问题下载中断再次拉取是支持断点续传的不用删掉重新拉直接再执行一次 pull 或compose up即可。6.6 一个小提醒不要用node:latest标签演示项目 Dockerfile 里写的node:20-alpine是精确锁定了大版本的。如果写node:latest半年后基础镜像更新你重新构建时可能拿到一个新的 Node 大版本应用代码未必兼容。我遇到过 Node 从 18 升到 20 之后某个依赖 API 行为变化导致线上小故障的情况。生产环境建议固定大版本甚至可以精确到小版本比如node:20.14.0-alpine。7. 最后再分享一个小技巧如何用一条命令完成整套部署装好这些以后如果你的服务器以后要跨机器部署或者项目要交给同事部署流程可以收敛成这几条命令git clone 项目地址 cd 项目目录 docker compose up -d --build就这三步。Nginx 配置、Node.js 依赖、网络关系全部在代码仓库里定义好了换一台机器不用再手动安装 Node.js、手动配置 Nginx、手动建网络。这也是 Docker 这套组合最吸引我的地方环境本身变成了代码的一部分任何人拿到项目都能复现一模一样的运行环境。我在实际使用中发现第一次搭这套东西可能花一个下午但第二、三次复制同一套模式到新项目时几乎不用看文档直接改端口和路径就能跑起来。希望这篇文章能帮你把第一个下午的时间压缩到一个小时以内。