部署配方 · 网络与访问
用 Caddy 做机架唯一的那道门
这台机架上所有服务都只能从同一个反向代理进来。选 Caddy 而不是 nginx 的理由只有一个:它用四行配置自己申请和续期证书,在家庭网络里,这一条就把最烦人的例行公事整个删掉了。下面是给九个服务做前端的 Caddyfile、给不出门的域名用的内部 CA 技巧,以及两个各花掉一下午的坑。
| 容器镜像 | 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 | 暴露 | 用途 |
|---|---|---|---|---|
| :80 | 80 | tcp | 对公网 | ACME HTTP-01 challenge and the permanent redirect to https |
| :443 | 443 | tcp | 对公网 | every web front end on the rack, one hostname each |
| :2019 | 2019 | tcp | 仅本机 | admin API, bound to loopback only |
部署步骤
先建机架网络(如果没有)
代理只能看到和它同网络的容器名。整个机架一个桥接网络,创建一次,每个 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写完 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启动,并盯着第一次签发
首次启动会为公网域名做 ACME 校验。如果 80 端口还没从公网可达,就是在这里发现的——日志里会写得很清楚。
run cd /srv/homelab/caddy && docker compose up -d docker logs -f --tail 40 caddy在自己的设备上信任内部根证书
只对 .lan 域名需要。把根证书拷出来,加进每台需要看到锁的设备的信任存储。
run docker cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/beaconbox-root.crt ls -l /tmp/beaconbox-root.crt热加载,而不是重启
永远不要为了应用配置改动而重启代理:重启会同时掐掉所有服务的长连接。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 域名就都有锁了,而且不弹警告。
# 把本地根证书拷出来,在 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 和只允许局域网来源。于是每个服务块就三行。
(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,而容器自己一切正常。
docker exec caddy wget -qO- http://immich-server:2283/api/server/ping
# 在 caddy 容器里都失败,那问题在网络,不在配置日志,以及怎么让它别长大
访问日志默认关闭,对一个家庭来说这是对的默认值。排查问题时逐个域名打开,交给宿主机的 logrotate 处理,别让容器自己管。
值得长期留的是证书续期那一行。每月 grep 一次,就知道自动 HTTPS 还在不在干活。
docker logs --since 720h caddy 2>&1 | grep -i 'certificate obtained' | tail -5compose 文件
整份文件放在 /srv/homelab/caddy/compose.yaml。标签固定版本号,不用 latest——树莓派上回滚比升级麻烦得多。
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 常驻。这是机架上少数几个可以「不计算」的容器之一。
- 代理应该和其它服务跑在同一台派上吗?
- 应该,直到它成了你什么都访问不了的原因。跑在同一块板子上不花钱、还让网络结构保持简单;等你加第二台派的时候,再把它挪到更闲的那台上,这样重启忙的那台不会连大门一起关掉。