Docker Compose 部署带 HTTPS 的站点:Nginx 反代 + 自动证书实战

把一个 Web 应用跑在 Docker 里并不难,难的是对外提供服务时要同时处理域名、HTTPS 证书、反向代理和证书续期:证书三个月过期一次,续期后容器不重载就用不上新证书;反代头少配一个,后端拿到的就是错误的客户端 IP 和协议。本文用 Docker Compose 编排一个典型的「应用 + Nginx 反代」组合,把证书申请、续期、重载这条链路一次打通,并覆盖 WebSocket、大文件上传、日志与滚动更新等日常运维动作。

适用环境:一台能被公网访问的 Linux 主机(云主机或有公网 IP 的物理机),域名已解析到该主机。下文以应用镜像自备、Nginx 官方镜像做反代为例,证书签发机构为 Let's Encrypt。

前置条件

  • 主机已安装 Docker Engine 24+ 与 Docker Compose v2(docker compose version 可正常输出)。
  • 域名 app.example.com 已添加 A 记录指向主机公网 IP(示例用 192.0.2.10)。
  • 主机防火墙与云安全组已放行 80、443 端口;80 端口是 HTTP-01 验证的必经通道,不能只开 443。
  • 应用本身已打成镜像,例如 registry.example.com/team/webapp:1.4.0,能独立以容器方式跑通。

第一步:规划目录与 Compose 文件

把配置、证书、日志都收敛到一个目录里,后续迁移和备份就是打包一个文件夹。推荐结构如下:

/srv/webapp/
├── docker-compose.yml
├── nginx/
│   └── conf.d/app.conf
├── certs/            # 证书文件(由签发工具写入)
└── logs/nginx/       # Nginx 日志

Compose 文件定义两个服务:app 只监听内网端口不直接对外,nginx 对外监听 80/443 并反代到 app。证书目录以只读方式挂给 nginx,另一个名为 webroot 的卷供证书签发时做 HTTP-01 验证:

services:
  app:
    image: registry.example.com/team/webapp:1.4.0
    container_name: webapp
    restart: unless-stopped
    environment:
      - TZ=Asia/Shanghai
    expose:
      - "8080"
    volumes:
      - ./data:/app/data

  nginx:
    image: nginx:1.27-alpine
    container_name: webapp-nginx
    restart: unless-stopped
    depends_on:
      - app
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./certs:/etc/nginx/certs:ro
      - webroot:/var/www/certbot
      - ./logs/nginx:/var/log/nginx

volumes:
  webroot:

注意 app 用 expose 而不是 ports:端口只在 Compose 网络内可见,不暴露到宿主机,这是减少攻击面的基本习惯。

第二步:申请证书——两种做法与取舍

做法一:certbot 容器 + webroot 验证

先写一份只监听 80 的临时 Nginx 配置,让验证文件能被访问,再用 certbot 官方镜像申请证书:

docker compose up -d nginx
docker run --rm \
  -v /srv/webapp/certs:/etc/letsencrypt \
  -v webapp_webroot:/var/www/certbot \
  certbot/certbot certonly --webroot -w /var/www/certbot \
  -d app.example.com --email ops@example.com \
  --agree-tos --no-eff-email

证书会落在 certs/live/app.example.com/ 下,包含 fullchain.pem(证书链)和 privkey.pem(私钥)。certbot 的优势是官方维护、文档全;代价是镜像较大,且 live 目录是符号链接,挂载给别的容器时要连 archive 目录一起处理,稍显繁琐。

做法二:acme.sh 脚本

acme.sh 是纯 Shell 实现,体积小、支持的 DNS 服务商多,证书直接输出为普通文件,挂载更直观:

curl https://get.acme.sh | sh -s email=ops@example.com
~/.acme.sh/acme.sh --issue -d app.example.com -w /srv/webapp/webroot
~/.acme.sh/acme.sh --install-cert -d app.example.com \
  --fullchain-file /srv/webapp/certs/fullchain.pem \
  --key-file /srv/webapp/certs/privkey.pem \
  --reloadcmd "docker exec webapp-nginx nginx -s reload"

acme.sh 自带 cron 续期,并且 --install-cert 时声明的 reloadcmd 会在每次续期后自动执行——这正好解决了「续期后容器不重载」的痛点。本文后续以 acme.sh 的产物路径为准;选 certbot 的话把配置里的证书路径换成其 live 目录即可。取舍结论:单机、图省事选 acme.sh;团队已有 certbot 运维规范就沿用 certbot,不要混用。

第三步:编写正式的 Nginx 反代配置

证书到手后,把 nginx/conf.d/app.conf 换成正式配置。80 端口只做两件事:放行验证路径、其余跳转 HTTPS;443 端口做 TLS 终止与反代:

server {
    listen 80;
    server_name app.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    http2 on;
    server_name app.example.com;

    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_session_cache   shared:SSL:10m;

    client_max_body_size 50m;

    location / {
        proxy_pass http://app:8080;
        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_read_timeout 60s;
    }

    location /ws/ {
        proxy_pass http://app:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 3600s;
    }
}

几个容易漏的要点:第一,反代头里的 X-Forwarded-Proto 必须传,后端生成跳转链接、拼接回调地址时要靠它判断协议,缺了会出现 HTTPS 页面里跳回 HTTP 的问题。第二,WebSocket 必须单独配 Upgrade 与 Connection 头并把 proxy_http_version 设为 1.1,同时把读超时放大,否则长连接会被默认 60 秒切断。第三,client_max_body_size 默认只有 1MB,有文件上传的站点不改必然报 413。第四,proxy_pass 里的主机名 app 是 Compose 服务名,Docker 内置 DNS 会解析到容器。

第四步:启动并处理续期衔接

cd /srv/webapp
docker compose up -d
docker compose ps
docker exec webapp-nginx nginx -t

续期衔接的关键是:证书文件更新后,必须让 Nginx 重新加载。acme.sh 路线已在 install-cert 时声明了 reloadcmd,续期由它自带的 cron 触发,无需再写脚本。certbot 路线则在宿主机加一条 cron:

17 3 * * * root docker run --rm -v /srv/webapp/certs:/etc/letsencrypt -v webapp_webroot:/var/www/certbot certbot/certbot renew --quiet && docker exec webapp-nginx nginx -s reload

注意续期检查每天跑一次即可,Let's Encrypt 只在证书剩余有效期不足 30 天时才真正续签。reload 是平滑重载,不会断开已有连接,比 restart 更适合生产。

验证

curl -I http://app.example.com
curl -I https://app.example.com
echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null | grep -E "subject|issuer|Verify return code"

预期结果:HTTP 请求返回 301 并跳到 HTTPS;HTTPS 返回应用自身的响应头;openssl 输出里证书主题为 app.example.com、签发者为 Let's Encrypt,且 Verify return code: 0 (ok)。再用浏览器打开站点,确认地址栏无证书警告、上传一个大于 1MB 的文件不报 413 即算通过。

常用运维命令

docker compose logs -f --tail=200 nginx     # 跟踪日志
docker compose exec app sh                  # 进容器排查
docker compose pull app && docker compose up -d app   # 滚动更新单个服务
docker compose restart nginx                # 配置变更后重启(大改动时)

更新流程建议:先改 Compose 里的镜像标签,pull 拉新镜像,up -d 只重建变更的服务;Nginx 仅改配置时优先 nginx -t 校验后 reload,避免重启造成短暂中断。

常见问题

1. 打开站点报 502 Bad Gateway。先看 docker compose logs nginx,若出现 connect() failed (111: Connection refused) upstream: "http://app:8080",说明 app 容器没起来或没监听 8080,用 docker compose ps 和应用日志定位;若报 upstream timed out,多半是应用处理慢,检查 proxy_read_timeout 与应用自身性能,不要无脑调大超时掩盖问题。

2. 浏览器提示证书链不完整(部分客户端报 ERR_CERT_AUTHORITY_INVALID,浏览器却正常)。这是因为 ssl_certificate 只配了站点证书、缺中间证书。务必使用 fullchain.pem(含站点证书 + 中间证书链)而不是 cert.pem,acme.sh 的 --fullchain-file 和 certbot 的 fullchain.pem 都是完整链,配错文件名是最常见原因。

3. 申请证书时验证失败,报 Invalid response 或连接超时。按顺序排查:域名解析是否已生效(dig +short app.example.com 应返回 192.0.2.10)、80 端口是否放行、Nginx 的 acme-challenge 路径与 webroot 卷是否对得上。先在本机 curl http://127.0.0.1/.well-known/acme-challenge/test 自测文件可达,再向 CA 发起申请,避免触发 Let's Encrypt 的失败次数限制。

4. 续期成功但站点仍显示旧证书。证书文件已更新、Nginx 未重载。对照第四步检查 reloadcmd 或 cron 是否真的执行,再 docker exec webapp-nginx nginx -s reload 手动验证一次。

小结

这套组合的核心就三件事:Compose 把应用与反代的边界划清(应用不对公网暴露)、Nginx 配置把反代头、WebSocket、上传大小这三个易漏项一次配对、证书续期必须与 reload 绑定成一个动作。把这三点固化下来,单机站点的 HTTPS 运维基本可以做到无人值守,剩下的精力留给应用本身即可。

发表回复

后才能评论