Skip to content
twinkling.top服务CaddyEN
板型any Pi; this is the lightest container on the rack
内核6.6.51+rpt-rpi-v8
空闲温度48 °C
内核架构arm64
参考机:Pi 4B 8GB / Bookworm 64-bit

部署配方 · 网络与访问

用 Caddy 做机架唯一的那道门

这台机架上所有服务都只能从同一个反向代理进来。选 Caddy 而不是 nginx 的理由只有一个:它用四行配置自己申请和续期证书,在家庭网络里,这一条就把最烦人的例行公事整个删掉了。下面是给九个服务做前端的 Caddyfile、给不出门的域名用的内部 CA 技巧,以及两个各花掉一下午的坑。

caddy:2.9.1-alpine · 2026-08-18

Caddy rack plate: board stack and host ports
服务参数
容器镜像caddy:2.9.1-alpine
宿主端口:80/tcp, :443/tcp, :2019/tcp
数据目录/srv/homelab/caddy/data
内存预留45 MB
CPU 上限0.20 vCPU (cpus: "0.20")
更新节奏every minor release; the config format is stable, and Caddy's own automatic HTTPS is the part worth keeping current
ARM 兼容arm64 native; the alpine tag is 60 MB on disk and starts in under a second
端口映射与暴露面
宿主容器proto暴露用途
:8080tcp对公网ACME HTTP-01 challenge and the permanent redirect to https
:443443tcp对公网every web front end on the rack, one hostname each
:20192019tcp仅本机admin API, bound to loopback only

部署步骤

  1. 先建机架网络(如果没有)

    代理只能看到和它同网络的容器名。整个机架一个桥接网络,创建一次,每个 compose 文件用 external 引用。

    run
    docker network create rack --subnet 172.20.0.0/24 --gateway 172.20.0.1 || true
    docker network ls --filter name=rack
  2. 写完 Caddyfile 先校验,再启动

    caddy validate 只解析文件、不碰证书。先跑一遍,能把配置错误从崩溃循环变成一行报错。

    run
    sudo mkdir -p /srv/homelab/caddy/{data,config}
    docker run --rm -v /srv/homelab/caddy/Caddyfile:/etc/caddy/Caddyfile:ro caddy:2.9.1-alpine caddy validate --config /etc/caddy/Caddyfile
  3. 启动,并盯着第一次签发

    首次启动会为公网域名做 ACME 校验。如果 80 端口还没从公网可达,就是在这里发现的——日志里会写得很清楚。

    run
    cd /srv/homelab/caddy && docker compose up -d
    docker logs -f --tail 40 caddy
  4. 在自己的设备上信任内部根证书

    只对 .lan 域名需要。把根证书拷出来,加进每台需要看到锁的设备的信任存储。

    run
    docker cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/beaconbox-root.crt
    ls -l /tmp/beaconbox-root.crt
  5. 热加载,而不是重启

    永远不要为了应用配置改动而重启代理:重启会同时掐掉所有服务的长连接。caddy reload 是原地换配置。

    run
    docker exec caddy caddy reload --config /etc/caddy/Caddyfile
    docker exec caddy caddy list-modules | head -3

一道门,换来了什么

整台机架只有两个公网端口:80 和 443,都在 Caddy 上。此外不做任何转发,于是全屋的攻击面就是这一个本来就为暴露而设计的二进制程序。

它还顺手解决了端口号问题。代理后面每个服务都能用上游默认端口——Grafana 3000、Immich 2283、Uptime Kuma 3001——你按名字访问,不用背数字。端口分段依然存在,但那是给局域网内直连的面板用的;日常你敲的是 grafana.lan。

给 .lan 域名签内部证书

公共 ACME 不可能给一个公网 DNS 里不存在的名字签发证书,而手工自签的证书得在每台设备上重新信任一次。Caddy 用 tls internal 实现的,正是 Let's Encrypt 官方文档里给私有网络的那套做法:跑一个本地 CA,给主机名签发,每台设备只需要装一次根证书。

根证书落在容器数据卷的 /data/caddy/pki/authorities/local/root.crt。把它拷到笔记本、加进信任存储,所有 .lan 域名就都有锁了,而且不弹警告。

shell
# 把本地根证书拷出来,在 Mac 上信任它
sudo docker cp caddy:/data/caddy/pki/authorities/local/root.crt ./beaconbox-root.crt
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ./beaconbox-root.crt

只有一份 Caddyfile

这里没有 include 体系,也没有 sites-available 目录,因为不需要。这个体量的 Caddyfile 一屏就能读完,而一屏读得完的文件,才是你改动之前真的会通读一遍的文件。

(secure) 片段管响应头策略,(lan) 片段管内部 TLS 和只允许局域网来源。于是每个服务块就三行。

Caddyfile
(secure) {
  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains"
    X-Content-Type-Options "nosniff"
    Referrer-Policy "strict-origin-when-cross-origin"
  }
}

(lan) {
  tls internal
  @notlan not remote_ip 192.168.10.0/24 172.20.0.0/24
  respond @notlan "not here" 403
}

rack.twinkling.top {
  import secure
  reverse_proxy jellyfin:8096
}

grafana.lan {
  import lan
  reverse_proxy grafana:3000
}

immich.lan {
  import lan
  reverse_proxy immich-server:2283
}

直接写容器名

reverse_proxy 后面可以直接写容器名,因为 Caddy 和其它服务在同一个自定义桥接网络上。这也是为什么本机架每一份 compose 文件末尾都是 networks: [rack],而这个网络本身只创建一次、以 external: true 引用。

代价是 container_name 变得很重要。改掉容器名,所有指向旧名字的代理条目都会开始返回 502,而容器自己一切正常。

shell
docker exec caddy wget -qO- http://immich-server:2283/api/server/ping
# 在 caddy 容器里都失败,那问题在网络,不在配置

日志,以及怎么让它别长大

访问日志默认关闭,对一个家庭来说这是对的默认值。排查问题时逐个域名打开,交给宿主机的 logrotate 处理,别让容器自己管。

值得长期留的是证书续期那一行。每月 grep 一次,就知道自动 HTTPS 还在不在干活。

shell
docker logs --since 720h caddy 2>&1 | grep -i 'certificate obtained' | tail -5

compose 文件

整份文件放在 /srv/homelab/caddy/compose.yaml。标签固定版本号,不用 latest——树莓派上回滚比升级麻烦得多。

compose.yaml
services:
  caddy:
    image: caddy:2.9.1-alpine
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "127.0.0.1:2019:2019"
    environment:
      ACME_EMAIL: [email protected]
    volumes:
      - /srv/homelab/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - /srv/homelab/caddy/data:/data
      - /srv/homelab/caddy/config:/config
    networks: [rack]

networks:
  rack:
    external: true

加固清单

  • 管理 API 绑定在 127.0.0.1:2019,局域网里随手一个 curl 改不了配置
  • 没有任何服务直接暴露;所有域名都解析到 Caddy,由 Caddy 决定谁能进
  • 给两个自己没有登录机制的面板加基本认证,密码哈希用 caddy hash-password 生成
  • 安全响应头(HSTS、X-Content-Type-Options、Referrer-Policy)写在片段里,每个站点块统一引入
  • .lan 域名用 tls internal 自签,只有两个真正出门的域名申请正式证书

备份方案

Caddyfile 放在 git 里,那才是真正的备份。/srv/homelab/caddy/data 存着 ACME 账户密钥和已签发的证书,每周复制一次;丢了它只是每个域名重新签发一次,不会造成中断。

上线后验证

  • curl -I https://rack.twinkling.top 返回 200,并且带 HSTS 响应头
  • 装过内部根证书的设备上,.lan 域名显示有效锁标
  • 首次启动后 docker logs caddy 里能看到公网域名的 'certificate obtained successfully'
  • 2019 端口在树莓派本机可访问,从局域网另一台机器访问被拒

踩过的坑

  • Caddy 的自动 HTTPS 会给它见到的每个域名申请公网证书,包括 jellyfin.lan。域名在公网解析不到,校验就会失败,站点永远起不来——私有名字必须显式写 tls internal。
  • 另一个容器占了 80 端口,会让 ACME HTTP 校验以连接被拒失败,而你的浏览器却因为缓存证书看着一切正常。用 ss -ltnp 确认 80 到底在谁手里。
  • alpine 镜像里除了 busybox 没有别的工具。想在容器里用 dig 或 curl 排查,要么换镜像变体,要么从旁边的容器里测。

硬件与选型问答

家庭机架用 Caddy 还是 nginx?
想让证书变成别人的问题就用 Caddy——在家庭网络里你确实想。已经熟悉 nginx、又需要 Lua 和缓存模块,就用 nginx。在家庭流量水平下性能差异量不出来;配置上的差异是四行文件对比 certbot 定时器加 cron 加续期钩子。
反代在 4GB 的派上占多少内存?
九个域名大约 45MB 常驻。这是机架上少数几个可以「不计算」的容器之一。
代理应该和其它服务跑在同一台派上吗?
应该,直到它成了你什么都访问不了的原因。跑在同一块板子上不花钱、还让网络结构保持简单;等你加第二台派的时候,再把它挪到更闲的那台上,这样重启忙的那台不会连大门一起关掉。