自托管笔记应用:配置项覆盖与反代要点
环境变量不是默认值而是强制值——每次启动都会覆盖界面里的修改,这是"改了密码不生效"的根因。另记单/多用户模式、数据卷属主、共享网络下的监听地址与 TLS 终止边界。
症状:界面里改的口令,重启一次就没了
在 Web 界面里改管理员口令,界面提示成功,当场也能用新口令登录。几天后容器因故重启,新口令失效,旧口令又能进。第一反应通常是"没保存成功",于是再改一遍,如此循环。
根因不在保存,而在配置的优先级。容器启动时读到的环境变量(例如 SB_USER)在每次启动时都会重新写一遍,把运行时的值覆盖掉。界面上做的修改是"运行时状态",只在本次进程生命周期内有效。
原理:环境变量是强制值,不是默认值
同一个配置项在部署里通常有两种语义,混淆二者就会踩这个坑:
| 语义 | 生效时机 | 在界面改过之后 |
|---|---|---|
| 首次初始化的默认值 | 只在数据目录为空时写入 | 修改保留 |
| 每次启动的强制值 | 每次启动都写回 | 修改被覆盖 |
判断某一项属于哪一种,不需要读源码:改掉它,然后只重启进程、不重建容器:
# restart 复用已有容器配置,环境变量仍是创建时那一份
docker restart notes
如果重启后该值回到了 compose 文件里写的那个,它就是强制值。这个判据对任何自托管应用都适用。
补充一点容易混淆的:docker restart 不会重新读 compose 文件,所以它复现的是"覆盖"这件事本身;要让新值生效,必须改 compose 后重建:
docker compose up -d --force-recreate
正确做法:单一配置来源
把 compose 文件当作这类配置的唯一来源,规则就简单了:
- 口令、端口、监听地址、单用户开关只写在一处(compose 的
environment),不在界面里改 - 改口令的流程固定为:改 compose →
docker compose up -d --force-recreate→ 验证登录 - 宿主上若另外存了一份口令副本(例如一个 600 权限的文件,供备份脚本或 API 客户端读取),它是记录而不是配置源。两份必须同源同步,否则就是下一个"为什么 API 认证失败"的排查起点
顺带一个同类坑:镜像的 ENTRYPOINT 已经包含可执行文件时,command 只能传参数,不能把二进制路径再写一遍。重复会把 argv 变成 二进制 二进制 数据目录,应用在参数解析阶段直接退出,表现为无限重启(restart: unless-stopped 下会一直转圈)。看日志第一行就能分辨:参数错误是启动即退出,配置错误通常能起来再失败。
单用户还是多用户
单用户模式(SB_SINGLE=true 加命令行参数 --single)只有一个账号,不开放注册,没有用户目录隔离的开销。多用户模式则需要考虑注册策略、用户空间划分和每个人各自的凭据存放。
对一台自建服务器、一个使用者的场景,单用户是默认答案:认证面越小越好。多用户的价值主要在协作,如果只有自己在用,多出来的只是更多需要维护的凭据和更宽的接口面。切换到多用户之前,先确认自己确实需要别人登录,而不是只需要把页面分享出去。
数据卷与属主
数据目录用 bind mount 挂进来,容器重建不影响它:
services:
notes:
image: zefhemel/silverbullet:latest
container_name: notes
restart: unless-stopped
volumes:
- /srv/app/space:/space
environment:
- SB_USER=admin:<口令>
- SB_PORT=3000
- SB_SINGLE=true
- SB_HOSTNAME=0.0.0.0
command: ["/space", "--single"] # 只传参数,不重复二进制路径
bind mount 与命名卷的差别在实际运维里比想象中大:
| bind mount | 命名卷 | |
|---|---|---|
| 位置 | 宿主指定目录 | Docker 管理目录(docker volume inspect 查 Mountpoint) |
| 直接读写 | tar / rsync 直接拷 |
需借一个 helper 容器,或到 Mountpoint 下操作 |
docker compose down -v |
不受影响 | 卷被删除 |
| 迁移 | 拷目录 | 先导出再导入 |
备份的可靠性很大程度上取决于"能不能用最普通的工具读到数据",这一点上 bind mount 优势明显。
属主与权限是第二个取舍点:
| 容器以 root 运行 | 指定 PUID/PGID | |
|---|---|---|
| 配置成本 | 无需处理权限 | 需先把宿主目录 chown 到对应 uid/gid |
| 宿主文件属主 | root,编辑与备份常需提权 | 与指定用户一致,rsync 无障碍 |
| 适用 | 只通过应用访问数据 | 需要直连编辑、自动备份、git 提交 |
PUID/PGID 是很多自托管镜像的通用做法;若选用的镜像不提供这两个变量,等效的替代方案是在 compose 里写 user: "<uid>:<gid>"。注意一个易忽略的后果:改变属主不会自动改写已有文件的所有者,切换后会突然出现写入失败,需要对该目录做一次 chown -R 再恢复。
反代 / 隧道下的三件事
监听地址:容器内绑全部网卡,宿主侧尽量不发布端口
应用要能被同一 Docker 网络里的其他容器(反向代理、隧道 connector)访问,就必须监听 0.0.0.0(即配置里的 SB_HOSTNAME=0.0.0.0)。只绑 127.0.0.1 时,只有容器自己连得上,反代一侧表现为连接被拒,日志里是典型的 502。
这看起来和"只绑回环更安全"矛盾,其实不矛盾——回环约束应该施加在宿主侧,而不是容器内:
# 方案 A:发布端口,但只绑宿主回环
ports:
- "127.0.0.1:3000:3000"
# 方案 B:不写 ports:,只加入共享网络,由反代/隧道按容器名访问
networks: [edge]
不要写 - "3000:3000"。它等价于绑到 0.0.0.0,任何能路由到这台机器的地址都可以直接连上,绕过反代上的一切规则。
base path:能绕开就绕开
把应用挂在子路径(https://example.com/notes/)下会引入一整类问题:应用生成的绝对链接(静态资源、WebSocket 地址、登录后的重定向)若不带前缀,全部 404;反代则必须在转发时决定剥离还是保留前缀,两者稍有出入就断。给一个独立子域、让应用始终工作在根路径 /,是成本最低的选择。
确实要用子路径时,验证方法是看应用自己发出的链接,而不是看首页能不能打开:
curl -sI https://example.com/notes/ | grep -i '^location'
curl -s https://example.com/notes/ | grep -oE '(src|href)="[^"]+"' | head
Location 头与资源路径都带前缀,才算真正支持。
TLS:边缘终止是对的,但有前提
经隧道发布时,TLS 在边缘终止,源站这一段可以是纯 HTTP——流量走的是 Docker 内部网络,不出宿主。但这条结论成立的前提是源站不可从公网直连:
- 没有发布到
0.0.0.0的端口 - 宿主上没有把该端口暴露给外部
- 防火墙没有因为它而开洞
三个条件任意一条不成立,明文 HTTP 就变成"任何人都能直接访问、并绕过边缘那一层的规则"。所以这不是"隧道帮我做了 TLS",而是"隧道这一侧不必再做 TLS"。核对方法只有一个:
ss -tlnp | grep -E ':(3000|443)\b'
看到 0.0.0.0:3000 或 [::]:3000 就说明前提不成立了。
备份:数据在哪、怎么拷
数据全在挂载点 /space 对应的宿主目录里,容器重建不丢,删除容器也不丢。命令短到不需要脚本:
tar czf /srv/backup/notes-$(date +%Y%m%d).tar.gz /srv/app/space/
一致性方面,笔记应用写入的是普通文件,单文件写入通常是"写临时文件再改名",因此边跑边打包最多拿到某个文件的上一个版本,不太会拿到半截内容(这一点是推测,未见应用明确说明)。但索引、缓存类文件在写入中的状态不做保证。要绝对一致,先停容器再打包。
恢复就是解包回同一路径 + 重建容器。这个过程之所以简单,原因在下一节。
文件即数据:这是选它的主要理由
这类空间型笔记应用(SilverBullet 属于这一类)的页面就是一个个普通的 .md 文件,没有把内容锁进专有数据库:
- 备份 = 拷目录
- 迁移 = 拷目录 + 起一个容器
- 版本管理 = 直接对目录做 git
- 应用坏了、上游停止维护 = 数据仍然是一堆能读的文本
代价也要清楚:没有数据库的强约束与事务,索引需要重建,库变大时索引占用内存会上升;跨文件的引用关系由应用自己解析,出问题时得看日志而不是查表。但对个人知识库这个规模,退出成本低的价值远大于这些代价——这是选它的核心理由,而不是它的编辑器有多好用。
诚实的取舍:认证强度与更新节奏由你负责
自托管意味着上游只提供"一个用户名 + 一个口令"的认证(推测,未在文档中见到二次验证或登录锁定策略),以及一个 latest 标签的镜像。剩下两件事是你的:
认证强度。 弱口令 + 公网可路由是最常见的失守组合,两个变量只要关掉一个就安全得多。具体做法:口令用随机长串(例如 openssl rand -hex 24),只存宿主上 600 权限的文件与 compose 引用;同时确认宿主没有把端口发布到 0.0.0.0。若确实需要把这类内部工具暴露到公网,更合适的做法是在边缘再加一层访问控制,见 为内部工具加一层访问控制。
更新节奏。 用 latest 标签时,镜像不会自动更新,但你也无法从标签看出当前跑的是哪个版本;重建容器的那天才会拿到新版本,而这次重建同时是一次未经测试的升级。稳妥的做法是固定到明确版本标签,升级前先做一次数据目录备份,并在升级后确认登录与写入都正常。