分类:DevOps 与部署 · 作者:站长 · 发布于 2026-09-15 · 标签:Docker / Caddy / Spring Boot / 反向代理 / HTTPS

问题背景

一个 Spring Boot 服务上线后,往往不只是「一个 Jar 包跑起来」这么简单。你的架构图上会有:

  • 前端静态资源:Vue/React 构建产物
  • API 后端:Spring Boot 容器(可能不止一个)
  • 管理面板:PGAdmin、RabbitMQ 控制台等工具
  • 上传资源:/uploads 目录的静态映射

你需要在同一台机器上暴露 443 端口,让所有这些服务共用同一个域名/端口,同时自动搞定 HTTPS。大多数人的第一反应是 Nginx + Certbot,但维护成本逐年走高:证书要手动续、CRON 要配、配置要分 site-available / sites-enabled。

本文将拆解我生产环境使用的方案——Caddy 作为 Docker 网关,前置代理多个 Spring Boot 服务和静态站点,一个 Caddyfile 解决所有路由+HTTPS。

为什么是 Caddy 而不是 Nginx

维度Nginx + CertbotCaddy
HTTPS 配置手动配 SSL 块 + certbot 定期续约(CRON)自动申请/续约 Let's Encrypt,零配置
配置复杂度每个站点要 server{} 块、ssl_certificate、proxy_pass一行 reverse_proxy,自动 HTTPS
重载部署nginx -s reload 或 systemctl reloadcaddy reload,原子切换不下线
Docker 集成需额外处理容器间网络、卷挂载认证官方镜像,天然适配 Compose 网络

简单说:如果你的目标是「快速、安全、少维护」地上线一组 Docker 服务,Caddy 比 Nginx 更省心。Nginx 在超高并发静态资源吞吐上仍有优势,但对大多数微服务中台场景,Caddy 的自动 HTTPS 和无缝重载是更实际的收益。

整体架构

Docker Compose 编排如下:

# compose.yaml
services:
  caddy:
    image: caddy:2.9-alpine
    restart: unless-stopped
    cap_add:
      - NET_ADMIN        # HTTP/3 QUIC 所需
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"    # QUIC
    volumes:
      - ./caddy:/etc/caddy           # Caddyfile 目录(注意是目录不是文件!)
      - caddy_data:/data             # 证书持久化——最重要的一步
      - caddy_config:/config
    networks:
      - web

  app-backend:
    image: app-backend:latest
    restart: unless-stopped
    expose:
      - "8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
    volumes:
      - uploads_data:/app/uploads
    networks:
      - web

  admin-tool:
    image: dpage/pgadmin4
    restart: unless-stopped
    expose:
      - "80"
    networks:
      - web

volumes:
  caddy_data:        # 外部名=myproject_caddy_data(防止 docker compose down 丢失)
  caddy_config:
  uploads_data:

networks:
  web:

⚠️ 黄金规则:/data 卷必须用 named volume + external 声明,绝不能是 bind mount 或匿名卷。Caddy 在此目录存储证书私钥和 OCSP 装订信息,丢失意味着所有证书重新申请,触发 Let's Encrypt 速率限制。

Caddyfile 配置实战

以下是我生产环境的 Caddyfile(已脱敏):

# 全局配置
{
  email admin@example.com
  # 默认 admin API 只监听容器内 localhost,不对外暴露
  admin localhost:2019
}

# 主站:blog.example.com — 静态站点 + API 反代
blog.example.com {
  # 静态根目录
  root * /srv/site-a
  file_server

  # API 反向代理到 Spring Boot 后端
  reverse_proxy /api/* app-backend:8080 {
    # 保留原始 Host 头
    header_up Host {host}
    # 传递真实客户端 IP
    header_up X-Real-IP {remote_host}
  }

  # 上传资源直接映射
  reverse_proxy /uploads/* app-backend:8080

  # 管理后台子路径
  reverse_proxy /admin/* app-backend:8080

  # 日志
  log {
    output file /data/access.log {
      roll_size 100mb
      roll_keep 5
    }
  }
}

# 子站 shop.example.com — 另一个 Spring Boot 服务(或纯静态)
shop.example.com {
  root * /srv/site-b
  file_server

  reverse_proxy /api/* pay-backend:8080
}

# Snippet 复用(生产环境加安全头)
(security) {
  header {
    X-Content-Type-Options "nosniff"
    X-Frame-Options "DENY"
    Referrer-Policy "strict-origin-when-cross-origin"
  }
}

关键细节

  • Caddyfile 必须是目录挂载:挂载 ./caddy/ 到 /etc/caddy(目录级),而不是 ./Caddyfile:/etc/caddy/Caddyfile(文件级)。后者会导致 caddy reload 失败,因为 Caddy 需要在配置目录写入临时文件。
  • 容器间通信用服务名:app-backend:8080 直接解析到 Docker 网络内的容器 IP,不用处理端口映射——后端容器只需 expose 而不需要 ports,减少攻击面。
  • 路径精确匹配:reverse_proxy /api/* backend:8080 只代理 /api/ 开头的请求,不会把前端路由也转发到后端。

优雅重载:零停机配置更新

修改 Caddyfile 后,不需要 docker compose restart caddy(会导致几秒连接中断),而是用:

# 验证配置语法(推荐先跑这一步)
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile

# 零停机重载
docker compose exec -w /etc/caddy caddy reload

caddy reload 的工作原理:向 admin API 发送新配置 → Caddy 解析并应用 → 如果新配置无效则自动回滚 → 已有连接继续运行不受影响。整个过程毫秒级原子切换。

配置重载出错自动回滚(不会炸服务),但 validate 步骤可以提前拦截语法错误,推荐脚本化:

#!/bin/bash
# caddy-reload.sh — CI/CD 部署后调用
set -e
cd /opt/myproject
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec -w /etc/caddy caddy reload
echo "Caddy reloaded OK"

踩坑记录

  1. 证书丢失怪现象
    现象:服务正常运行一段时间后,浏览器突然报 NET::ERR_CERT_COMMON_NAME_INVALID。排查发现 docker compose down + up 时,匿名卷被删除,Caddy 重新申请证书但尚未完成。
    解决:改名卷为 caddy_data,并在 volumes 段声明 external: true 或至少用顶级 named volume 确保 down 不删除。

  2. Spring Boot 获取不到真实 IP
    现象:后端日志里客户端 IP 全是 Docker 网关 IP(172.x.x.x)。
    原因:Caddy 反代后默认不传递真实 IP。
    解决:Caddyfile 加 header_up X-Real-IP {remote_host},Spring Boot 侧加 server.forward-headers-strategy=framework(Spring Boot 2.2+)。

  3. 文件上传大小限制
    Caddy 默认限制请求体 32MB,上传大文件会被截断返回 413。
    解决:在站点块加 request_body <size>,如 request_body 100MB。

最佳实践总结

  • ✅ 用 named volume 持久化 /data(证书)和 /config
  • ✅ 构建 Caddyfile 时用 (snippet) 提取通用安全头/日志,通过 import 复用
  • ✅ 所有后端容器只 expose 端口,不要映射到宿主机——减少攻击面
  • ✅ 部署脚本中先 caddy validate 再 caddy reload
  • ✅ Spring Boot 开启 forward-headers-strategy=framework 获取真实客户端 IP
  • ❌ 切忌直接将 Caddyfile 作为文件挂载——必须是目录挂载
  • ❌ 切忌用 :latest 标签——���版本号如 caddy:2.9-alpine

适用场景与局限

这套方案最适合 单机多服务 或 小规模微服务中台:

  • ✅ 2-10 个后端容器的 Docker 部署
  • ✅ 需要自动 HTTPS 且不想维护 Certbot CRON
  • ✅ 前端 SPA + Java 后端的标准 Web 架构
  • ❌ 大规模 K8s 集群——此时 Traefik 或 Ingress Controller 更强
  • ❌ 纯静态文件巨量吞吐——Nginx 仍有性能优势

但对本文目标场景——一台 VPS,几个 Spring Boot 服务,N 个前端站点——Caddy 是最简洁、最安全的入口网关,没有之一。