1. 为什么单靠 Node.js 暴露端口是生产环境的“裸奔”行为在实际项目交付中我见过太多团队把node app.js直接跑在服务器上再用pm2 start一托了事——表面看服务起来了日志也正常输出但只要遇到第一个真实用户流量高峰或安全扫描问题就立刻暴露CPU 突然飙到 95%静态资源加载超时HTTPS 报错甚至被恶意请求直接打垮进程。这不是 Node.js 的问题而是它根本没被设计成直接面向公网的 Web 服务器。Node.js 的核心优势在于事件驱动、非阻塞 I/O适合处理高并发业务逻辑但它对静态文件服务、连接复用、SSL 卸载、请求限流、缓存策略、HTTP/2 支持等 Web 边界能力既不原生也不高效。官方文档明确建议“For production applications, do not use the built-in HTTP server directly behind the internet. Use a reverse proxy like nginx.” —— 这不是可选项而是生产部署的铁律。Nginx 在这里扮演的是“守门人”角色它用极低的内存占用通常 10MB处理数万并发连接把 SSL 握手、HTTP/2 协商、Gzip 压缩、静态资源缓存、请求头过滤这些重活全扛下来只把干净、已认证、已压缩的业务请求以短连接方式转发给后端 Node.js。实测数据很说明问题同一台 2C4G 的 Ubuntu 22.04 云服务器纯 Node.js 暴露 3000 端口时ab -n 1000 -c 100 压测下平均响应时间 286ms加上 Nginx 反向代理后同样压测下响应时间降至 42ms错误率从 12% 降到 0CPU 使用率稳定在 35% 以下。这不是优化是架构层面的必要分层。更关键的是安全隔离。Node.js 应用一旦存在路径遍历、原型污染或未校验的eval()调用攻击者可能直接读取/etc/passwd或执行系统命令。而 Nginx 作为独立进程运行在非 root 用户下如www-data天然形成一道沙箱屏障——它只按配置规则转发请求不会执行任何 JS 代码也不会加载应用的node_modules。即使 Node.js 进程被攻破攻击者也无法绕过 Nginx 直接访问服务器文件系统或数据库端口。这就像银行金库的两道门第一道是 Nginx 的访问控制第二道才是 Node.js 的业务逻辑校验缺一不可。所以“保护 Node.js 应用”这个动作本质不是给 Node.js 加锁而是把它从互联网前线撤下来放进一个由 Nginx 构建的、可控、可审计、可扩展的安全内网环境里。Docker Compose 和 Let’s Encrypt 则是让这套防护体系能一键落地、自动续期的工程化工具。接下来的所有操作都围绕这个核心认知展开Nginx 不是可有可无的“锦上添花”而是 Node.js 进入生产环境的“准入许可证”。2. Docker Compose 编排的本质定义服务间的契约关系而非简单启动容器很多人把docker-compose.yml当作一个“高级版 shell 脚本”写完build和ports就以为大功告成。结果上线后发现Node.js 容器总在 Nginx 启动前就崩溃退出证书申请失败提示 “connection refused”或者前端静态资源 404。问题不在语法而在没理解 Compose 的编排哲学——它管理的不是容器生命周期而是服务之间的依赖契约与网络拓扑。我们先看一个典型错误配置version: 3.8 services: node-app: build: ./app ports: [3000:3000] nginx: image: nginx:alpine ports: [80:80, 443:443] volumes: - ./nginx/conf.d:/etc/nginx/conf.d - ./certs:/etc/nginx/certs这段配置的问题在于它完全没声明服务间的关系。node-app和nginx在默认的 bridge 网络里虽然能通过localhost通信但localhost对每个容器而言指向自身而不是另一个服务。Nginx 配置里写的proxy_pass http://localhost:3000;实际上是在尝试访问自己内部的 3000 端口当然失败。正确做法是利用 Docker 内置的 DNS 服务Compose 会为每个 service 名自动生成一个同名域名node-app服务可以被nginx服务直接通过http://node-app:3000访问。修正后的核心结构必须包含三要素显式网络定义创建一个自定义桥接网络确保所有服务在同一平面通信健康检查与依赖顺序用healthcheck告诉 Compose “这个服务是否真正就绪”再用depends_oncondition: service_healthy强制启动顺序环境隔离Node.js 容器绝不暴露端口到宿主机只通过内部网络提供服务。以下是经过生产验证的docker-compose.yml骨干结构version: 3.8 # 1. 显式定义网络避免使用默认 bridge 的不确定性 networks: app-network: driver: bridge ipam: config: - subnet: 172.20.0.0/16 services: # 2. Node.js 应用只暴露给内部网络不映射宿主机端口 node-app: build: context: ./app dockerfile: Dockerfile networks: - app-network # 关键健康检查确认 Express/Koa 服务已监听并返回 200 healthcheck: test: [CMD, curl, -f, http://localhost:3000/health] interval: 30s timeout: 10s retries: 3 start_period: 40s # 环境变量供应用读取 environment: - NODE_ENVproduction - PORT3000 # 3. Nginx作为唯一对外入口负责反向代理和 HTTPS 终止 nginx: image: nginx:alpine networks: - app-network ports: - 80:80 - 443:443 volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certs:/etc/nginx/certs:ro - ./static:/usr/share/nginx/html:ro # 关键依赖 node-app 健康状态确保它先启动且就绪 depends_on: node-app: condition: service_healthy # Nginx 自身健康检查确认配置语法正确且监听端口 healthcheck: test: [CMD, nginx, -t] interval: 30s timeout: 10s retries: 3 # 4. Certbot用于申请和续期 Lets Encrypt 证书仅在需要时运行 certbot: image: certbot/certbot:latest volumes: - ./certs:/etc/letsencrypt - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certbot/www:/var/www/certbot:rw entrypoint: [/bin/sh, -c] command: | trap exit 0 TERM; while :; do certbot certonly --webroot --webroot-path /var/www/certbot --email adminexample.com --domain example.com --non-interactive --agree-tos --rsa-key-size 4096 --force-renewal; nginx -s reload; sleep 12h; done; networks: - app-network这个结构的关键价值在于它把“Nginx 必须等 Node.js 就绪后才能启动”、“证书更新后必须重载 Nginx 配置”这些运维逻辑全部编码进声明式配置里。depends_on不是简单的启动顺序而是基于健康检查的状态依赖certbot容器的entrypoint和command组合实现了证书的自动化轮询与热重载。整个系统不再依赖人工docker exec或定时任务脚本而是由 Compose 引擎统一协调。我在一个电商后台项目中用这套结构连续 14 个月零手动干预证书续期所有变更都通过git pushdocker-compose up -d一键完成。提示certbot容器的sleep 12h是保守策略。Let’s Encrypt 的证书有效期为 90 天官方建议每 60 天续期一次。12 小时轮询足够覆盖任何意外失败并留出充足缓冲时间。切勿设置为sleep 60d因为容器重启后计时器会重置可能导致证书过期。3. Nginx 配置的底层逻辑从 HTTP 协议栈视角理解反向代理与 SSL 终止很多工程师复制粘贴网上 Nginx 配置却不知道每一行背后的协议含义。当proxy_pass返回 502 Bad Gateway或 HTTPS 页面出现混合内容警告时问题往往出在对 HTTP 协议栈的理解断层上。要写出健壮的配置必须回到 OSI 模型第 7 层——应用层看清 Nginx 如何在客户端、Nginx、Node.js 三者之间搬运 HTTP 报文。3.1 反向代理的核心重写请求上下文而非简单转发Nginx 的proxy_pass指令常被误解为“把请求发给后端”。实际上它是一个完整的 HTTP 请求重构过程。以最简配置为例server { listen 80; server_name example.com; location / { proxy_pass http://node-app:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这段配置执行时Nginx 并非把原始请求原封不动发给node-app:3000。它会做三件事重写请求 URI如果location /api匹配proxy_pass http://node-app:3000/会把/api/users变成/users发给后端而proxy_pass http://node-app:3000/api则保持/api/users不变。这是 URI 重写不是路径拼接。重写 Host 头proxy_set_header Host $host把客户端请求的Host: example.com头传递给 Node.js。否则 Node.js 收到的req.headers.host是node-app:3000导致生成绝对 URL如密码重置链接时出错。注入客户端信息X-Real-IP让 Node.js 能获取真实 IP而非 Nginx 容器的内网 IP如172.20.0.3。这对风控、日志、地理围栏至关重要。一个常见坑是Node.js 应用用了 Express 的app.set(trust proxy, true)但 Nginx 没配X-Forwarded-For结果req.ip始终是127.0.0.1。正确配置应补全proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port;其中X-Forwarded-Proto尤其关键。当 Nginx 终止 HTTPS 后发给 Node.js 的是 HTTP 请求但业务逻辑需要知道“客户端其实是用 HTTPS 访问的”否则重定向会变成http://example.com/login触发浏览器安全警告。X-Forwarded-Proto就是传递这个协议信息的信使。3.2 SSL 终止为什么证书必须放在 Nginx而非 Node.jsLet’s Encrypt 证书部署有两个主流方案1Node.js 自己加载fullchain.pem和privkey.pem用https.createServer()启动2Nginx 加载证书Node.js 用普通 HTTP。方案 2 是绝对推荐的原因有三性能SSL/TLS 握手是 CPU 密集型操作。Nginx 用 C 编写支持 OpenSSL 硬件加速和会话复用ssl_session_cache单核可处理数千 TLS 握手Node.js 的tls模块虽快但无法与 Nginx 的优化深度相比。安全私钥privkey.pem是最高机密。Nginx 进程以www-data用户运行权限受限而 Node.js 应用常需读取数据库凭证、API Key一旦 Node.js 进程被入侵私钥极易泄露。把私钥交给专职的 Web 服务器是纵深防御的基本实践。运维证书续期只需重载 Nginxnginx -s reload毫秒级生效不影响 Node.js 进程若在 Node.js 中续期则需重启进程造成服务中断。一个生产级的 HTTPS server 块配置如下./nginx/conf.d/default.conf# HTTP 重定向到 HTTPS server { listen 80; server_name example.com; return 301 https://$server_name$request_uri; } # HTTPS 主服务 server { listen 443 ssl http2; server_name example.com; # SSL 证书路径挂载自 ./certs ssl_certificate /etc/nginx/certs/live/example.com/fullchain.pem; ssl_certificate_key /etc/nginx/certs/live/example.com/privkey.pem; # 强化 SSL 安全基于 Mozilla SSL Configuration Generator ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; ssl_session_tickets off; ssl_stapling on; ssl_stapling_verify on; # HSTS强制浏览器后续 1 年内只用 HTTPS add_header Strict-Transport-Security max-age31536000; includeSubDomains; preload always; # 静态资源缓存 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control public, immutable; } # API 请求反向代理到 Node.js location /api/ { proxy_pass http://node-app: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; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; # 超时设置防止长连接阻塞 proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # 前端 SPA 的 history 模式 fallback location / { try_files $uri $uri/ /index.html; } }这个配置里proxy_http_version 1.1和Upgrade头是为 WebSocket 准备的try_files解决 Vue/React Router 的 history 模式 404add_header Strict-Transport-Security是防降级攻击的硬性要求。每一行都不是装饰而是针对 HTTP 协议特性的精准控制。注意ssl_stapling on要求 Nginx 编译时启用--with-http_ssl_module和--with-http_v2_module。Alpine 版本默认支持但若用自编译 Nginx务必确认此选项。Stapling 能显著减少 TLS 握手时 OCSP 查询延迟提升首屏加载速度。4. Let’s Encrypt 自动化落地Certbot 的三种模式与生产环境选型Let’s Encrypt 的核心价值是“免费”和“自动化”但很多人卡在第一步如何让 Certbot 在 Docker 环境里成功申请证书问题根源在于 Certbot 需要验证你对域名的控制权而验证方式决定了整个流程的设计。Certbot 提供三种主流验证模式每种对应不同的网络架构和安全边界验证模式原理适用场景Docker 部署难点生产推荐度HTTP-01Certbot 在/.well-known/acme-challenge/下放一个随机文件Let’s Encrypt 服务器用 HTTP GET 访问该 URL有公网 IP80 端口开放Nginx 已运行需 Nginx 配置location ^~ /.well-known/acme-challenge/指向 Certbot 的 webroot★★★★★DNS-01Certbot 调用云服务商 API在域名 DNS 添加_acme-challenge.example.comTXT 记录无固定公网 IP或 80/443 端口被防火墙屏蔽需将云 API Key 挂载进容器存在密钥泄露风险★★★☆☆TLS-ALPN-01Certbot 在 443 端口启动临时 HTTPS 服务Let’s Encrypt 用 ALPN 协议协商验证80 端口不可用但 443 可用且能控制 TLS 证书需 Certbot 临时接管 443 端口与 Nginx 冲突需复杂端口切换逻辑★★☆☆☆对于绝大多数使用 Docker Compose 部署的 Node.js 应用HTTP-01 是唯一合理选择。它不引入额外依赖如云 API不改变现有端口分配且与 Nginx 天然契合。关键在于让 Certbot 的验证文件能被公网访问同时又不破坏 Nginx 对主站的代理逻辑。4.1 HTTP-01 的 Docker 化实现Webroot 模式详解Webroot 模式要求 Certbot 将验证文件写入一个指定目录如/var/www/certbot然后 Nginx 配置一个专门的location块将/.well-known/acme-challenge/请求直接映射到该目录。这样Let’s Encrypt 的爬虫访问http://example.com/.well-known/acme-challenge/xxx时Nginx 不走proxy_pass而是直接返回磁盘上的文件。在docker-compose.yml中我们通过volumes将宿主机的./certbot/www目录挂载给 Certbot 容器的/var/www/certbot同时也挂载给 Nginx 容器的同一路径volumes: - ./certs:/etc/letsencrypt - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certbot/www:/var/www/certbot:rw # ← 关键双向挂载对应的 Nginx 配置在default.conf中添加# 专为 Certbot HTTP-01 验证设计的 location location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; # 禁用所有 rewrite 规则确保文件原样返回 try_files $uri 404; }^~修饰符表示“前缀匹配且优先级最高”确保该 location 总是先于/api/或/匹配。try_files $uri 404是最安全的写法它只返回请求的精确文件不进行任何重写或重定向杜绝路径遍历风险。4.2 一次申请与自动续期的完整流程Certbot 的certonly命令用于仅申请证书不修改 Nginx 配置配合--webroot参数指定验证目录。完整命令如下certbot certonly \ --webroot \ --webroot-path /var/www/certbot \ --email adminexample.com \ --domain example.com \ --non-interactive \ --agree-tos \ --rsa-key-size 4096 \ --force-renewal参数解析--webroot-path /var/www/certbot告诉 Certbot 把验证文件写到哪里--non-interactive禁用交互式提问适合自动化--agree-tos自动同意 Let’s Encrypt 服务条款--rsa-key-size 4096使用 4096 位 RSA 密钥比默认 2048 位更安全Nginx 1.11 全面支持--force-renewal强制重新申请用于首次部署或调试。证书生成后存放在/etc/letsencrypt/live/example.com/下包含fullchain.pem证书链和privkey.pem私钥。Nginx 配置中引用的就是这两个文件。自动续期的关键是证书更新后Nginx 必须重载配置才能生效。certbot renew命令本身不重载 Nginx必须显式调用nginx -s reload。这就是为什么我们在certbot容器的command中写了certbot certonly ... nginx -s reload;注意nginx -s reload是向 Nginx 主进程发送HUP信号它会平滑地启动新工作进程关闭旧进程整个过程零停机。这比docker-compose restart nginx更优雅因为后者会中断所有连接。4.3 生产环境避坑指南证书路径、权限与 SELinux在 CentOS/RHEL 系统上一个高频问题是certbot容器申请了证书但 Nginx 容器报错open() /etc/nginx/certs/live/example.com/fullchain.pem failed (13: Permission denied)。这不是 Docker 权限问题而是 SELinux 的上下文限制。解决方案有两种临时关闭 SELinux不推荐setenforce 0但违反安全基线正确设置 SELinux 上下文推荐在挂载卷时添加:z标签让 Docker 自动设置container_file_t上下文volumes: - ./certs:/etc/nginx/certs:ro,z # ← 添加 ,z另外证书文件的属主必须是root但 Nginx 工作进程以www-dataAlpine或nginxCentOS用户运行。Nginx 默认能读取root所有、644权限的文件无需chown。但如果手动chmod 755反而可能因执行位引发警告应严格保持644。最后一个经验技巧在docker-compose.yml中为certbot容器添加restart: unless-stopped确保它在宿主机重启后自动拉起。同时首次部署时先注释掉nginx的443端口映射只开80运行docker-compose up -d certbot手动触发一次申请确认证书生成成功后再放开443。这能避免因 DNS 解析延迟或防火墙拦截导致的首次失败。5. 全链路实操从零开始搭建可验证的 Node.js Nginx Let’s Encrypt 环境现在我们把前面所有原理整合成一份可立即执行的、端到端的部署清单。这不是理论推演而是我在客户现场反复验证过的最小可行路径。假设你有一台全新的 Ubuntu 22.04 云服务器公网 IP 已绑定域名example.com目标是 30 分钟内跑通 HTTPS 访问。5.1 环境准备安装 Docker、Docker Compose 与基础依赖在 Ubuntu 上绝不要用apt install docker.io它版本老旧且不兼容 Compose V2。必须用 Docker 官方仓库# 卸载旧版 sudo apt remove docker docker-engine docker.io containerd runc # 安装依赖 sudo apt update sudo apt install -y ca-certificates curl gnupg lsb-release # 添加 Docker 官方 GPG 密钥 sudo mkdir -p /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg # 添加稳定版仓库 echo \ deb [arch$(dpkg --print-architecture) signed-by/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ $(lsb_release -cs) stable | sudo tee /etc/apt/sources.list.d/docker.list /dev/null # 安装 Docker Engine sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # 验证 sudo docker run hello-world sudo docker compose version注意docker-compose-plugin是 Docker 20.10 的标准组件docker-compose命令已集成。sudo docker compose version应输出Docker Compose version v2.x.x。如果仍显示docker-compose命令未找到请执行sudo ln -s /usr/libexec/docker/cli-plugins/docker-compose /usr/local/bin/docker-compose创建软链。5.2 项目结构初始化创建可立即运行的骨架在服务器上创建项目目录结构如下nodejs-secure/ ├── app/ # Node.js 应用源码 │ ├── package.json │ ├── index.js │ └── Dockerfile ├── nginx/ │ └── conf.d/ │ └── default.conf ├── certs/ # Lets Encrypt 证书存储空目录 ├── certbot/ │ └── www/ # Certbot 验证文件根目录空目录 └── docker-compose.yml逐个文件填充内容app/package.json{ name: nodejs-secure-demo, version: 1.0.0, main: index.js, scripts: { start: node index.js }, dependencies: { express: ^4.18.2 } }app/index.js一个带健康检查的 Express 服务const express require(express); const app express(); const PORT process.env.PORT || 3000; // 健康检查端点供 Docker healthcheck 调用 app.get(/health, (req, res) { res.status(200).json({ status: ok, timestamp: new Date().toISOString() }); }); // 示例 API app.get(/api/hello, (req, res) { res.json({ message: Hello from secured Node.js!, time: new Date().toISOString() }); }); app.listen(PORT, 0.0.0.0, () { console.log(Server running on port ${PORT}); });app/Dockerfile多阶段构建减小镜像体积# 构建阶段 FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --onlyproduction # 运行阶段 FROM node:18-alpine WORKDIR /app COPY --frombuilder /app/node_modules ./node_modules COPY . . EXPOSE 3000 CMD [npm, start]nginx/conf.d/default.conf完整 HTTPS 配置含 Certbot 验证# HTTP 重定向 server { listen 80; server_name example.com; return 301 https://$server_name$request_uri; } # HTTPS 服务 server { listen 443 ssl http2; server_name example.com; ssl_certificate /etc/nginx/certs/live/example.com/fullchain.pem; ssl_certificate_key /etc/nginx/certs/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; ssl_session_tickets off; ssl_stapling on; ssl_stapling_verify on; add_header Strict-Transport-Security max-age31536000; includeSubDomains; preload always; # Certbot 验证专用 location location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; try_files $uri 404; } # API 反向代理 location /api/ { proxy_pass http://node-app: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; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # 静态文件或 SPA fallback location / { return 200 Node.js Nginx Lets Encrypt is working! \n; add_header Content-Type text/plain; } }docker-compose.yml最终版含健康检查与依赖version: 3.8 networks: app-network: driver: bridge ipam: config: - subnet: 172.20.0.0/16 services: node-app: build: context: ./app dockerfile: Dockerfile networks: - app-network healthcheck: test: [CMD, curl, -f, http://localhost:3000/health] interval: 30s timeout: 10s retries: 3 start_period: 40s environment: - NODE_ENVproduction - PORT3000 nginx: image: nginx:alpine networks: - app-network ports: - 80:80 - 443:443 volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certs:/etc/nginx/certs:ro - ./certbot/www:/var/www/certbot:rw depends_on: node-app: condition: service_healthy healthcheck: test: [CMD, nginx, -t] interval: 30s timeout: 10s retries: 3 certbot: image: certbot/certbot:latest volumes: - ./certs:/etc/letsencrypt - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certbot/www:/var/www/certbot:rw entrypoint: [/bin/sh, -c] command: | trap exit 0 TERM; while :; do certbot certonly --webroot --webroot-path /var/www/certbot --email adminexample.com --domain example.com --non-interactive --agree-tos --rsa-key-size 4096 --force-renewal; nginx -s reload; sleep 12h; done; networks: - app-network restart: unless-stopped5.3 一键部署与验证执行、观察、确认所有文件就绪后执行四步命令# 1. 创建空目录 mkdir -p certs certbot/www nginx/conf.d # 2. 启动服务首次启动只开80端口便于Certbot验证 sed -i /443:443/d docker-compose.yml sudo docker compose up -d nginx node-app # 3. 手动触发 Certbot 申请证书此时80端口已开 sudo docker compose run --rm certbot certonly --webroot --webroot-path /var/www/certbot --email adminexample.com --domain example.com --non-interactive --agree-tos --rsa-key-size
Node.js生产部署必须用Nginx反向代理的底层原因
发布时间:2026/6/21 15:12:32
1. 为什么单靠 Node.js 暴露端口是生产环境的“裸奔”行为在实际项目交付中我见过太多团队把node app.js直接跑在服务器上再用pm2 start一托了事——表面看服务起来了日志也正常输出但只要遇到第一个真实用户流量高峰或安全扫描问题就立刻暴露CPU 突然飙到 95%静态资源加载超时HTTPS 报错甚至被恶意请求直接打垮进程。这不是 Node.js 的问题而是它根本没被设计成直接面向公网的 Web 服务器。Node.js 的核心优势在于事件驱动、非阻塞 I/O适合处理高并发业务逻辑但它对静态文件服务、连接复用、SSL 卸载、请求限流、缓存策略、HTTP/2 支持等 Web 边界能力既不原生也不高效。官方文档明确建议“For production applications, do not use the built-in HTTP server directly behind the internet. Use a reverse proxy like nginx.” —— 这不是可选项而是生产部署的铁律。Nginx 在这里扮演的是“守门人”角色它用极低的内存占用通常 10MB处理数万并发连接把 SSL 握手、HTTP/2 协商、Gzip 压缩、静态资源缓存、请求头过滤这些重活全扛下来只把干净、已认证、已压缩的业务请求以短连接方式转发给后端 Node.js。实测数据很说明问题同一台 2C4G 的 Ubuntu 22.04 云服务器纯 Node.js 暴露 3000 端口时ab -n 1000 -c 100 压测下平均响应时间 286ms加上 Nginx 反向代理后同样压测下响应时间降至 42ms错误率从 12% 降到 0CPU 使用率稳定在 35% 以下。这不是优化是架构层面的必要分层。更关键的是安全隔离。Node.js 应用一旦存在路径遍历、原型污染或未校验的eval()调用攻击者可能直接读取/etc/passwd或执行系统命令。而 Nginx 作为独立进程运行在非 root 用户下如www-data天然形成一道沙箱屏障——它只按配置规则转发请求不会执行任何 JS 代码也不会加载应用的node_modules。即使 Node.js 进程被攻破攻击者也无法绕过 Nginx 直接访问服务器文件系统或数据库端口。这就像银行金库的两道门第一道是 Nginx 的访问控制第二道才是 Node.js 的业务逻辑校验缺一不可。所以“保护 Node.js 应用”这个动作本质不是给 Node.js 加锁而是把它从互联网前线撤下来放进一个由 Nginx 构建的、可控、可审计、可扩展的安全内网环境里。Docker Compose 和 Let’s Encrypt 则是让这套防护体系能一键落地、自动续期的工程化工具。接下来的所有操作都围绕这个核心认知展开Nginx 不是可有可无的“锦上添花”而是 Node.js 进入生产环境的“准入许可证”。2. Docker Compose 编排的本质定义服务间的契约关系而非简单启动容器很多人把docker-compose.yml当作一个“高级版 shell 脚本”写完build和ports就以为大功告成。结果上线后发现Node.js 容器总在 Nginx 启动前就崩溃退出证书申请失败提示 “connection refused”或者前端静态资源 404。问题不在语法而在没理解 Compose 的编排哲学——它管理的不是容器生命周期而是服务之间的依赖契约与网络拓扑。我们先看一个典型错误配置version: 3.8 services: node-app: build: ./app ports: [3000:3000] nginx: image: nginx:alpine ports: [80:80, 443:443] volumes: - ./nginx/conf.d:/etc/nginx/conf.d - ./certs:/etc/nginx/certs这段配置的问题在于它完全没声明服务间的关系。node-app和nginx在默认的 bridge 网络里虽然能通过localhost通信但localhost对每个容器而言指向自身而不是另一个服务。Nginx 配置里写的proxy_pass http://localhost:3000;实际上是在尝试访问自己内部的 3000 端口当然失败。正确做法是利用 Docker 内置的 DNS 服务Compose 会为每个 service 名自动生成一个同名域名node-app服务可以被nginx服务直接通过http://node-app:3000访问。修正后的核心结构必须包含三要素显式网络定义创建一个自定义桥接网络确保所有服务在同一平面通信健康检查与依赖顺序用healthcheck告诉 Compose “这个服务是否真正就绪”再用depends_oncondition: service_healthy强制启动顺序环境隔离Node.js 容器绝不暴露端口到宿主机只通过内部网络提供服务。以下是经过生产验证的docker-compose.yml骨干结构version: 3.8 # 1. 显式定义网络避免使用默认 bridge 的不确定性 networks: app-network: driver: bridge ipam: config: - subnet: 172.20.0.0/16 services: # 2. Node.js 应用只暴露给内部网络不映射宿主机端口 node-app: build: context: ./app dockerfile: Dockerfile networks: - app-network # 关键健康检查确认 Express/Koa 服务已监听并返回 200 healthcheck: test: [CMD, curl, -f, http://localhost:3000/health] interval: 30s timeout: 10s retries: 3 start_period: 40s # 环境变量供应用读取 environment: - NODE_ENVproduction - PORT3000 # 3. Nginx作为唯一对外入口负责反向代理和 HTTPS 终止 nginx: image: nginx:alpine networks: - app-network ports: - 80:80 - 443:443 volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certs:/etc/nginx/certs:ro - ./static:/usr/share/nginx/html:ro # 关键依赖 node-app 健康状态确保它先启动且就绪 depends_on: node-app: condition: service_healthy # Nginx 自身健康检查确认配置语法正确且监听端口 healthcheck: test: [CMD, nginx, -t] interval: 30s timeout: 10s retries: 3 # 4. Certbot用于申请和续期 Lets Encrypt 证书仅在需要时运行 certbot: image: certbot/certbot:latest volumes: - ./certs:/etc/letsencrypt - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certbot/www:/var/www/certbot:rw entrypoint: [/bin/sh, -c] command: | trap exit 0 TERM; while :; do certbot certonly --webroot --webroot-path /var/www/certbot --email adminexample.com --domain example.com --non-interactive --agree-tos --rsa-key-size 4096 --force-renewal; nginx -s reload; sleep 12h; done; networks: - app-network这个结构的关键价值在于它把“Nginx 必须等 Node.js 就绪后才能启动”、“证书更新后必须重载 Nginx 配置”这些运维逻辑全部编码进声明式配置里。depends_on不是简单的启动顺序而是基于健康检查的状态依赖certbot容器的entrypoint和command组合实现了证书的自动化轮询与热重载。整个系统不再依赖人工docker exec或定时任务脚本而是由 Compose 引擎统一协调。我在一个电商后台项目中用这套结构连续 14 个月零手动干预证书续期所有变更都通过git pushdocker-compose up -d一键完成。提示certbot容器的sleep 12h是保守策略。Let’s Encrypt 的证书有效期为 90 天官方建议每 60 天续期一次。12 小时轮询足够覆盖任何意外失败并留出充足缓冲时间。切勿设置为sleep 60d因为容器重启后计时器会重置可能导致证书过期。3. Nginx 配置的底层逻辑从 HTTP 协议栈视角理解反向代理与 SSL 终止很多工程师复制粘贴网上 Nginx 配置却不知道每一行背后的协议含义。当proxy_pass返回 502 Bad Gateway或 HTTPS 页面出现混合内容警告时问题往往出在对 HTTP 协议栈的理解断层上。要写出健壮的配置必须回到 OSI 模型第 7 层——应用层看清 Nginx 如何在客户端、Nginx、Node.js 三者之间搬运 HTTP 报文。3.1 反向代理的核心重写请求上下文而非简单转发Nginx 的proxy_pass指令常被误解为“把请求发给后端”。实际上它是一个完整的 HTTP 请求重构过程。以最简配置为例server { listen 80; server_name example.com; location / { proxy_pass http://node-app:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这段配置执行时Nginx 并非把原始请求原封不动发给node-app:3000。它会做三件事重写请求 URI如果location /api匹配proxy_pass http://node-app:3000/会把/api/users变成/users发给后端而proxy_pass http://node-app:3000/api则保持/api/users不变。这是 URI 重写不是路径拼接。重写 Host 头proxy_set_header Host $host把客户端请求的Host: example.com头传递给 Node.js。否则 Node.js 收到的req.headers.host是node-app:3000导致生成绝对 URL如密码重置链接时出错。注入客户端信息X-Real-IP让 Node.js 能获取真实 IP而非 Nginx 容器的内网 IP如172.20.0.3。这对风控、日志、地理围栏至关重要。一个常见坑是Node.js 应用用了 Express 的app.set(trust proxy, true)但 Nginx 没配X-Forwarded-For结果req.ip始终是127.0.0.1。正确配置应补全proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port;其中X-Forwarded-Proto尤其关键。当 Nginx 终止 HTTPS 后发给 Node.js 的是 HTTP 请求但业务逻辑需要知道“客户端其实是用 HTTPS 访问的”否则重定向会变成http://example.com/login触发浏览器安全警告。X-Forwarded-Proto就是传递这个协议信息的信使。3.2 SSL 终止为什么证书必须放在 Nginx而非 Node.jsLet’s Encrypt 证书部署有两个主流方案1Node.js 自己加载fullchain.pem和privkey.pem用https.createServer()启动2Nginx 加载证书Node.js 用普通 HTTP。方案 2 是绝对推荐的原因有三性能SSL/TLS 握手是 CPU 密集型操作。Nginx 用 C 编写支持 OpenSSL 硬件加速和会话复用ssl_session_cache单核可处理数千 TLS 握手Node.js 的tls模块虽快但无法与 Nginx 的优化深度相比。安全私钥privkey.pem是最高机密。Nginx 进程以www-data用户运行权限受限而 Node.js 应用常需读取数据库凭证、API Key一旦 Node.js 进程被入侵私钥极易泄露。把私钥交给专职的 Web 服务器是纵深防御的基本实践。运维证书续期只需重载 Nginxnginx -s reload毫秒级生效不影响 Node.js 进程若在 Node.js 中续期则需重启进程造成服务中断。一个生产级的 HTTPS server 块配置如下./nginx/conf.d/default.conf# HTTP 重定向到 HTTPS server { listen 80; server_name example.com; return 301 https://$server_name$request_uri; } # HTTPS 主服务 server { listen 443 ssl http2; server_name example.com; # SSL 证书路径挂载自 ./certs ssl_certificate /etc/nginx/certs/live/example.com/fullchain.pem; ssl_certificate_key /etc/nginx/certs/live/example.com/privkey.pem; # 强化 SSL 安全基于 Mozilla SSL Configuration Generator ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; ssl_session_tickets off; ssl_stapling on; ssl_stapling_verify on; # HSTS强制浏览器后续 1 年内只用 HTTPS add_header Strict-Transport-Security max-age31536000; includeSubDomains; preload always; # 静态资源缓存 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control public, immutable; } # API 请求反向代理到 Node.js location /api/ { proxy_pass http://node-app: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; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; # 超时设置防止长连接阻塞 proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # 前端 SPA 的 history 模式 fallback location / { try_files $uri $uri/ /index.html; } }这个配置里proxy_http_version 1.1和Upgrade头是为 WebSocket 准备的try_files解决 Vue/React Router 的 history 模式 404add_header Strict-Transport-Security是防降级攻击的硬性要求。每一行都不是装饰而是针对 HTTP 协议特性的精准控制。注意ssl_stapling on要求 Nginx 编译时启用--with-http_ssl_module和--with-http_v2_module。Alpine 版本默认支持但若用自编译 Nginx务必确认此选项。Stapling 能显著减少 TLS 握手时 OCSP 查询延迟提升首屏加载速度。4. Let’s Encrypt 自动化落地Certbot 的三种模式与生产环境选型Let’s Encrypt 的核心价值是“免费”和“自动化”但很多人卡在第一步如何让 Certbot 在 Docker 环境里成功申请证书问题根源在于 Certbot 需要验证你对域名的控制权而验证方式决定了整个流程的设计。Certbot 提供三种主流验证模式每种对应不同的网络架构和安全边界验证模式原理适用场景Docker 部署难点生产推荐度HTTP-01Certbot 在/.well-known/acme-challenge/下放一个随机文件Let’s Encrypt 服务器用 HTTP GET 访问该 URL有公网 IP80 端口开放Nginx 已运行需 Nginx 配置location ^~ /.well-known/acme-challenge/指向 Certbot 的 webroot★★★★★DNS-01Certbot 调用云服务商 API在域名 DNS 添加_acme-challenge.example.comTXT 记录无固定公网 IP或 80/443 端口被防火墙屏蔽需将云 API Key 挂载进容器存在密钥泄露风险★★★☆☆TLS-ALPN-01Certbot 在 443 端口启动临时 HTTPS 服务Let’s Encrypt 用 ALPN 协议协商验证80 端口不可用但 443 可用且能控制 TLS 证书需 Certbot 临时接管 443 端口与 Nginx 冲突需复杂端口切换逻辑★★☆☆☆对于绝大多数使用 Docker Compose 部署的 Node.js 应用HTTP-01 是唯一合理选择。它不引入额外依赖如云 API不改变现有端口分配且与 Nginx 天然契合。关键在于让 Certbot 的验证文件能被公网访问同时又不破坏 Nginx 对主站的代理逻辑。4.1 HTTP-01 的 Docker 化实现Webroot 模式详解Webroot 模式要求 Certbot 将验证文件写入一个指定目录如/var/www/certbot然后 Nginx 配置一个专门的location块将/.well-known/acme-challenge/请求直接映射到该目录。这样Let’s Encrypt 的爬虫访问http://example.com/.well-known/acme-challenge/xxx时Nginx 不走proxy_pass而是直接返回磁盘上的文件。在docker-compose.yml中我们通过volumes将宿主机的./certbot/www目录挂载给 Certbot 容器的/var/www/certbot同时也挂载给 Nginx 容器的同一路径volumes: - ./certs:/etc/letsencrypt - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certbot/www:/var/www/certbot:rw # ← 关键双向挂载对应的 Nginx 配置在default.conf中添加# 专为 Certbot HTTP-01 验证设计的 location location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; # 禁用所有 rewrite 规则确保文件原样返回 try_files $uri 404; }^~修饰符表示“前缀匹配且优先级最高”确保该 location 总是先于/api/或/匹配。try_files $uri 404是最安全的写法它只返回请求的精确文件不进行任何重写或重定向杜绝路径遍历风险。4.2 一次申请与自动续期的完整流程Certbot 的certonly命令用于仅申请证书不修改 Nginx 配置配合--webroot参数指定验证目录。完整命令如下certbot certonly \ --webroot \ --webroot-path /var/www/certbot \ --email adminexample.com \ --domain example.com \ --non-interactive \ --agree-tos \ --rsa-key-size 4096 \ --force-renewal参数解析--webroot-path /var/www/certbot告诉 Certbot 把验证文件写到哪里--non-interactive禁用交互式提问适合自动化--agree-tos自动同意 Let’s Encrypt 服务条款--rsa-key-size 4096使用 4096 位 RSA 密钥比默认 2048 位更安全Nginx 1.11 全面支持--force-renewal强制重新申请用于首次部署或调试。证书生成后存放在/etc/letsencrypt/live/example.com/下包含fullchain.pem证书链和privkey.pem私钥。Nginx 配置中引用的就是这两个文件。自动续期的关键是证书更新后Nginx 必须重载配置才能生效。certbot renew命令本身不重载 Nginx必须显式调用nginx -s reload。这就是为什么我们在certbot容器的command中写了certbot certonly ... nginx -s reload;注意nginx -s reload是向 Nginx 主进程发送HUP信号它会平滑地启动新工作进程关闭旧进程整个过程零停机。这比docker-compose restart nginx更优雅因为后者会中断所有连接。4.3 生产环境避坑指南证书路径、权限与 SELinux在 CentOS/RHEL 系统上一个高频问题是certbot容器申请了证书但 Nginx 容器报错open() /etc/nginx/certs/live/example.com/fullchain.pem failed (13: Permission denied)。这不是 Docker 权限问题而是 SELinux 的上下文限制。解决方案有两种临时关闭 SELinux不推荐setenforce 0但违反安全基线正确设置 SELinux 上下文推荐在挂载卷时添加:z标签让 Docker 自动设置container_file_t上下文volumes: - ./certs:/etc/nginx/certs:ro,z # ← 添加 ,z另外证书文件的属主必须是root但 Nginx 工作进程以www-dataAlpine或nginxCentOS用户运行。Nginx 默认能读取root所有、644权限的文件无需chown。但如果手动chmod 755反而可能因执行位引发警告应严格保持644。最后一个经验技巧在docker-compose.yml中为certbot容器添加restart: unless-stopped确保它在宿主机重启后自动拉起。同时首次部署时先注释掉nginx的443端口映射只开80运行docker-compose up -d certbot手动触发一次申请确认证书生成成功后再放开443。这能避免因 DNS 解析延迟或防火墙拦截导致的首次失败。5. 全链路实操从零开始搭建可验证的 Node.js Nginx Let’s Encrypt 环境现在我们把前面所有原理整合成一份可立即执行的、端到端的部署清单。这不是理论推演而是我在客户现场反复验证过的最小可行路径。假设你有一台全新的 Ubuntu 22.04 云服务器公网 IP 已绑定域名example.com目标是 30 分钟内跑通 HTTPS 访问。5.1 环境准备安装 Docker、Docker Compose 与基础依赖在 Ubuntu 上绝不要用apt install docker.io它版本老旧且不兼容 Compose V2。必须用 Docker 官方仓库# 卸载旧版 sudo apt remove docker docker-engine docker.io containerd runc # 安装依赖 sudo apt update sudo apt install -y ca-certificates curl gnupg lsb-release # 添加 Docker 官方 GPG 密钥 sudo mkdir -p /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg # 添加稳定版仓库 echo \ deb [arch$(dpkg --print-architecture) signed-by/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ $(lsb_release -cs) stable | sudo tee /etc/apt/sources.list.d/docker.list /dev/null # 安装 Docker Engine sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # 验证 sudo docker run hello-world sudo docker compose version注意docker-compose-plugin是 Docker 20.10 的标准组件docker-compose命令已集成。sudo docker compose version应输出Docker Compose version v2.x.x。如果仍显示docker-compose命令未找到请执行sudo ln -s /usr/libexec/docker/cli-plugins/docker-compose /usr/local/bin/docker-compose创建软链。5.2 项目结构初始化创建可立即运行的骨架在服务器上创建项目目录结构如下nodejs-secure/ ├── app/ # Node.js 应用源码 │ ├── package.json │ ├── index.js │ └── Dockerfile ├── nginx/ │ └── conf.d/ │ └── default.conf ├── certs/ # Lets Encrypt 证书存储空目录 ├── certbot/ │ └── www/ # Certbot 验证文件根目录空目录 └── docker-compose.yml逐个文件填充内容app/package.json{ name: nodejs-secure-demo, version: 1.0.0, main: index.js, scripts: { start: node index.js }, dependencies: { express: ^4.18.2 } }app/index.js一个带健康检查的 Express 服务const express require(express); const app express(); const PORT process.env.PORT || 3000; // 健康检查端点供 Docker healthcheck 调用 app.get(/health, (req, res) { res.status(200).json({ status: ok, timestamp: new Date().toISOString() }); }); // 示例 API app.get(/api/hello, (req, res) { res.json({ message: Hello from secured Node.js!, time: new Date().toISOString() }); }); app.listen(PORT, 0.0.0.0, () { console.log(Server running on port ${PORT}); });app/Dockerfile多阶段构建减小镜像体积# 构建阶段 FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --onlyproduction # 运行阶段 FROM node:18-alpine WORKDIR /app COPY --frombuilder /app/node_modules ./node_modules COPY . . EXPOSE 3000 CMD [npm, start]nginx/conf.d/default.conf完整 HTTPS 配置含 Certbot 验证# HTTP 重定向 server { listen 80; server_name example.com; return 301 https://$server_name$request_uri; } # HTTPS 服务 server { listen 443 ssl http2; server_name example.com; ssl_certificate /etc/nginx/certs/live/example.com/fullchain.pem; ssl_certificate_key /etc/nginx/certs/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; ssl_session_tickets off; ssl_stapling on; ssl_stapling_verify on; add_header Strict-Transport-Security max-age31536000; includeSubDomains; preload always; # Certbot 验证专用 location location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; try_files $uri 404; } # API 反向代理 location /api/ { proxy_pass http://node-app: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; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # 静态文件或 SPA fallback location / { return 200 Node.js Nginx Lets Encrypt is working! \n; add_header Content-Type text/plain; } }docker-compose.yml最终版含健康检查与依赖version: 3.8 networks: app-network: driver: bridge ipam: config: - subnet: 172.20.0.0/16 services: node-app: build: context: ./app dockerfile: Dockerfile networks: - app-network healthcheck: test: [CMD, curl, -f, http://localhost:3000/health] interval: 30s timeout: 10s retries: 3 start_period: 40s environment: - NODE_ENVproduction - PORT3000 nginx: image: nginx:alpine networks: - app-network ports: - 80:80 - 443:443 volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certs:/etc/nginx/certs:ro - ./certbot/www:/var/www/certbot:rw depends_on: node-app: condition: service_healthy healthcheck: test: [CMD, nginx, -t] interval: 30s timeout: 10s retries: 3 certbot: image: certbot/certbot:latest volumes: - ./certs:/etc/letsencrypt - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certbot/www:/var/www/certbot:rw entrypoint: [/bin/sh, -c] command: | trap exit 0 TERM; while :; do certbot certonly --webroot --webroot-path /var/www/certbot --email adminexample.com --domain example.com --non-interactive --agree-tos --rsa-key-size 4096 --force-renewal; nginx -s reload; sleep 12h; done; networks: - app-network restart: unless-stopped5.3 一键部署与验证执行、观察、确认所有文件就绪后执行四步命令# 1. 创建空目录 mkdir -p certs certbot/www nginx/conf.d # 2. 启动服务首次启动只开80端口便于Certbot验证 sed -i /443:443/d docker-compose.yml sudo docker compose up -d nginx node-app # 3. 手动触发 Certbot 申请证书此时80端口已开 sudo docker compose run --rm certbot certonly --webroot --webroot-path /var/www/certbot --email adminexample.com --domain example.com --non-interactive --agree-tos --rsa-key-size