Nextcloud 自托管:rootless Podman + Cloudflare Tunnel + 宿主机 Caddy
九月 13, 2026 [网络与安全] #podman #caddy背景
需求很简单:要一个自己掌控的网盘,能在浏览器里直接编辑 Markdown(我的笔记都是 .md),顺带支持 WebDAV 和手机端同步。目标是家里那台 192.168.2.160(下面叫 160,Debian 13 的 VM),要求:
- 不暴露任何公网入站端口(家里没有固定公网 IP 的玩法),用 Cloudflare Tunnel 出站连出去;
- 机器上不提供明文 HTTP 服务;
- 尽量别动系统全局设置(比如为了绑 443 去改
ip_unprivileged_port_start)。
最后跑起来的链路是这样:
公网用户 ──https──> Cloudflare 边缘(TLS 终结,无 A 记录直连)
│ cloudflared 出站长连接
▼
160 上 cloudflared
├─ ACME challenge ──> http://localhost:80 (caddy,HTTP-01)
└─ 其余 ────────────> https://localhost:443 (caddy,LE 证书)
│
caddy :443(apt 系统服务)
│ reverse_proxy
▼
127.0.0.1:8080(rootless podman)
│
nextcloud + mariadb + redis
内网访问则靠 split-horizon DNS 把 nextcloud.juzhong.xyz 直接解析到 160,走同一条 caddy 链路,吃同一张真证书。
分层选型
为什么是 rootless Podman + quadlet
160 上本来就跑着 rootless podman。用 quadlet(~/.config/containers/systemd/*.container)把三个容器写成 systemd 用户服务,好处是不用维护 compose,systemctl --user 直接管,开机自启只依赖 linger。
关键点:容器只发布到回环 127.0.0.1:8080:80。rootless podman 绑不了 1024 以下端口,而我又不想改 net.ipv4.ip_unprivileged_port_start(那等于把所有特权端口开放给本机所有非特权进程,为一个网盘不值得)。
为什么 443 给宿主机 Caddy
既然 rootless 绑不了 443,就让宿主机装一个 caddy(Debian apt 包,root 系统服务)来监听 80/443,反代到回环的 8080。Caddy 自动申请 Let's Encrypt 证书、自动 HTTP→HTTPS 跳转,80 端口只用来应答 ACME challenge 和 301,不提供任何明文内容。
试过在 podman 里跑 caddy + DNS-01(自建带 cloudflare 插件的镜像),但复杂度明显更高:要维护自定义镜像、把 CA 从容器里导出来信任。换成宿主机 apt caddy 后,一个 Caddyfile 就结束了。
为什么证书只能走 HTTP-01
Cloudflare 代理会在边缘终结 TLS,回源由 cloudflared 发起。这意味着源站根本收不到 TLS-ALPN-01 的握手,所以 caddy 里必须:
tls {
issuer acme {
disable_tlsalpn_challenge
}
}
然后在隧道里把 challenge 路径单独路由到本机 80:
ingress:
- hostname: nextcloud.juzhong.xyz
path: ^/\.well-known/acme-challenge/
service: http://localhost:80
- hostname: nextcloud.juzhong.xyz
service: https://localhost:443
originRequest:
originServerName: nextcloud.juzhong.xyz
- service: http_status:404
签发和续期都靠这条路径回源,实测 LE 证书正常下发。
部署过程
1. 容器与初始化
三个 quadlet:nextcloud-app(nextcloud:apache)、nextcloud-db(mariadb:11)、nextcloud-redis(redis:7-alpine),加一个 nextcloud.network。数据全部落在 ~/nextcloud/{html,data,db,redis},密钥放 ~/nextcloud/.env(600)。
两个小坑:
- app 容器
TimeoutStartSec要给足(我给了 900s),首次初始化要建库、装应用,比较慢; - 初始化完成后用
occ补配置:
podman exec -u www-data nextcloud-app php occ config:system:set \
trusted_proxies 2 --value=10.89.0.0/24
podman exec -u www-data nextcloud-app php occ config:system:set \
overwriteprotocol --value=https
podman exec -u www-data nextcloud-app php occ config:system:set \
overwrite.cli.url --value=https://nextcloud.juzhong.xyz
trusted_proxies 里的 10.89.0.0/24 是 rootless podman 网络里 caddy 连接所见的网关地址——不填它,Nextcloud 不认 X-Forwarded-For,后面会看到真实的客户端 IP 全部丢失。
背景任务用 systemd timer 每 5 分钟跑一次 cron.php,并把 backgroundjobs_mode 设为 cron。
2. 隧道
cloudflared 的隧道创建需要账号级凭据 ~/.cloudflared/cert.pem(一次浏览器授权生成)。我之前在 VPS 上已经登录过同一个 Cloudflare 账号,所以直接把 cert.pem 复制到 160 即可,不用重新授权:
cloudflared tunnel create nextcloud
cloudflared tunnel route dns nextcloud nextcloud.juzhong.xyz
建完会得到隧道凭据 <UUID>.json。运行隧道只需要这个 JSON,cert.pem 是账号级的(能管理账号下所有隧道和 DNS),所以 bootstrap 完就 shred -u 删掉了,需要管理隧道时再从 VPS 临时取回。
cloudflared 以 systemd 用户服务常驻,config.yml 里就是上面那段 ingress。
3. Caddy
sudo apt install caddy
Caddyfile(简化版):
nextcloud.juzhong.xyz {
tls {
issuer acme {
disable_tlsalpn_challenge
}
}
encode gzip
reverse_proxy 127.0.0.1:8080 {
trusted_proxies 127.0.0.1 ::1
}
}
最后那行 trusted_proxies 是踩坑之后补的,见下一节。
踩坑
坑 1:Markdown 编辑器应用装不上
Nextcloud 应用商店里有个 Markdown Editor(files_markdown),但它的 max-version 还没跟上 Nextcloud 34,装了也用不了。好在 NC34 内置的 text 应用(8.0.0)本身就支持直接编辑 text/markdown 和 text/plain,点开 .md 就是所见即所得编辑器,自动保存。笔记场景再装个 notes(6.0.2)就够了。
坑 2:真实客户端 IP 全部变成网关地址
部署完看日志,发现所有失败登录都记成同一个 IP 10.89.0.14(podman 网关):
Login failed: 'wrong' (Remote IP: '10.89.0.14')
这个问题的严重性不只是日志难看:Nextcloud 的防爆破是按 IP 累计的,所有公网用户挤在同一个 IP 上,等于一个人爆破就会连累所有人被锁。
排查过程有点绕:先看 Apache access log,客户端地址是 podman 网关(正常,caddy 从回环连进来);Nextcloud 的 getRemoteAddress() 逻辑是「如果 REMOTE_ADDR 在 trusted_proxies 里,就解析 X-Forwarded-For,跳过可信代理,取第一个不可信的 IP」。所以问题出在 XFF 本身。
翻了 caddy 2.6.2 的源码(reverseproxy.go 的 addForwardedHeaders)才明白:
// 如果直接客户端不在 trustedProxies 里,trusted=false,
// 那么 clientXFF = clientIP,直接覆写 XFF
clientXFF := clientIP
prior, ok, omit := allHeaderValues(req.Header, "X-Forwarded-For")
if trusted && ok && prior != "" {
clientXFF = prior + ", " + clientXFF
}
cloudflared 从 127.0.0.1 连进来,带来了 Cloudflare 边缘设置的真实客户端 IP 的 XFF;但 caddy 默认不信任它,把 XFF 覆写成了 127.0.0.1。Nextcloud 一看 127.0.0.1 也是可信代理,跳过,最后回退到 REMOTE_ADDR——也就是 podman 网关。
修复就是在 reverse_proxy 里声明信任回环:
reverse_proxy 127.0.0.1:8080 {
trusted_proxies 127.0.0.1 ::1
}
注意 Debian 打包的 caddy 2.6.2 还没有全局 trusted_proxies 选项(2.7+ 才有),只能写在 reverse_proxy 子指令里。
修完验证:
公网失败登录 -> Remote IP: 222.129.x.x(真实公网 IP)
内网直连失败登录 -> Remote IP: 192.168.2.160(真实来源)
坑 3:宿主 OOM 把 VM 里的文件写成了 NUL
160 的宿主是台 15G 内存、没有 swap 的小主机,给 VM 分了 12G。某次宿主 OOM killer 杀了 qemu,VM 恢复后发现两个文件变成了全 NUL 字节:
~/.cloudflared/config.yml~/.config/systemd/user/cloudflared.service
数据目录倒是无损(可能刚写完盘)。从模板重新渲染这两个文件就恢复了。教训:宿主加 swap,或者给 VM 降内存。这种「文件还在、内容全没了」的故障模式挺隐蔽的,file 或 wc -c 一看大小对但内容是 \0。
验证
https://nextcloud.juzhong.xyz/status.php公网 200;- 内网
--resolve直连 160:443,TLS 校验ssl_verify_result=0(真证书); - WebDAV PUT/GET/DELETE 往返正常;
- 浏览器打开 .md → Text 编辑器 → 编辑 → 自动保存,WebDAV 拉回来内容一致;
- 公网失败登录日志记录真实客户端 IP。
配置和安装脚本整理在仓库 homelab/nextcloud/(install.sh 幂等,模板含 quadlet、cloudflared ingress、Caddyfile)。
小结
整套方案的层次其实很清晰:Cloudflare 负责公网入口和 TLS 终结,caddy 负责源站证书与反代,podman 负责应用隔离,隧道负责穿透。踩的坑里,最值得记的是 caddy 的 XFF 覆写——它不会报错,只会让所有用户悄悄共享同一个 IP;如果不是专门去核对登录日志,很可能一直发现不了。另一个是 cert.pem 和隧道凭据的区别:前者是账号级的,用完就该删。