先说结论

这次最大的经验不是某一条 Docker 参数,而是:Afilmory 当前的官方构建更接近“把官方 SaaS 环境打成镜像”,而不是“开箱即用的通用 Self-hosted 镜像”。

把它直接搬到自己的 NAS,会连续撞上一批在官方环境里天然成立、在自建环境里却并不成立的默认假设:域名结构、租户识别、静态资源 Host、登录方式、HTTPS 安全上下文、对象存储,甚至数据库迁移和运行时依赖都可能需要自己接住。

前端尤其明显。有些域名行为是在构建阶段就决定的,afilmory.art 相关规则并不一定会随着后台的 System Settings 一起变化。结果就是:后台看起来已经改了,浏览器里跑的那份代码却还活在官方 SaaS 世界里。

最终真正稳定下来的思路不是继续“猜官方环境还缺哪个变量”,而是把它当成一个需要适配层的上游项目:上游镜像负责主体功能,自建环境通过可重复执行的补丁、统一网关和兼容存储把边界收回来。

最后的部署结构

部署过程中 CDN 回源方式调整过。早期试过 EdgeOne 通过 HTTP 31841 回源,最后改成了 HTTPS 回源 NAS 的 31443。HTTP 入口可以继续作为局域网调试或备用入口,但生产链路尽量统一到 HTTPS。

最终结构可以简化成:

TEXT
                         ┌──────────────────────────────┐
EdgeOne CDN ──HTTPS─────▶│ :31443                       │
                         │                              │
直连 HTTPS ─────────────▶│ gateway                     │
                         │ ready-jump.top 证书          │
                         │                              │
HTTP / 调试 ──:31841────▶│                              │
                         ├──────── / ─────────▶ Core    │
                         │                              │
                         └── /afilmory/* ────▶ RustFS   │
                                              │
                                              └─ NAS 本地照片

Core ─────────▶ PostgreSQL
     └────────▶ Redis

这里有几个刻意做出的边界:

  • Core 不直接暴露宿主机端口。
  • RustFS 也不直接暴露给公网。
  • 对外只认网关,域名、证书、路径转发都从一个入口出去。
  • 照片虽然物理上存在 NAS 本地,但通过 RustFS 提供 S3 兼容接口给 Afilmory。
  • 照片的公开地址尽量使用 /afilmory/... 这样的相对路径,而不是写死某个 IP、端口或协议。

这种结构的价值是 CDN、直连 HTTPS、局域网 HTTP 可以复用同一套应用数据,不需要为了不同入口反复改数据库里的资源 URL。

真正踩到的坑

现象 原因 最后的处理
构建失败 NAS 环境缺 Git,包管理器本身还有依赖问题,直接使用 Git Build Context 并不稳定 下载源码压缩包,在本地源码目录构建,不再让生产部署依赖宿主机 Git
Core 一启动就报 Cannot find package 'sharp' 官方 Core 最终运行层没有完整带上 Builder 所需的 Node 运行时依赖 在自建补丁阶段检查并补齐 sharp,不要只看系统里有没有 libvips
PostgreSQL migration 报 55P04 migration 给 enum 增加 pending 后,在同一个事务里马上使用这个新值;PostgreSQL 要求新 enum 值先提交 对全新安装的 migration 做兼容补丁,让 pending 从初始 enum 就存在,后续重复 ALTER 变成 no-op
Root 账号初始化失败 Better Auth 使用了默认 secret 显式配置 BETTER_AUTH_SECRET,再让 RootAccountProvisioner 完成初始化
登录页 404、提示“空间不可用” 后端会根据 Host 判断当前属于哪个 workspace,基础域名仍处于官方默认值,当前访问域名匹配不到租户 在数据库中设置自己的基础域名,并创建实际使用的 workspace,例如 nas
静态资源 404 前端构建结果里存在 static.afilmory.art 一类官方域名假设,后端还可能把资源地址改写为 static.<baseDomain> 给前端打补丁,资源尽量改成当前域名下的相对路径
登录页没有任何可用登录方式 自建子域环境下,页面逻辑更偏向官方 OAuth 流程;一个 OAuth Provider 都没配时,邮箱密码入口又没有正常露出来 补丁让邮箱/密码表单始终可用,OAuth 只是可选项
明明登录成功,页面却提示“意外错误” HTTP 页面不是 Secure Context,浏览器里的 crypto.randomUUID() 不可用;接口本身可能已经成功,下一段前端代码却直接异常 在页面里注入兼容实现;更重要的是生产入口最终统一到 HTTPS
后端无法选择 NAS 本地目录作为照片存储 当前存储抽象支持 S3 / OSS / COS / GitHub / B2 等,不直接把 local 当完整后端 用 RustFS 在 NAS 本地目录之上提供 S3 兼容接口;当时 MinIO 镜像获取也不顺利,因此改用 RustFS
套 CDN 后照片打不开 照片地址仍然是 http://...:31842/... 一类直连 URL;HTTPS 页面会遇到 Mixed Content,同时请求也绕过 CDN 通过网关统一转发 RustFS,并把照片地址改为 /afilmory/... 相对路径

这些问题单看每一个都不算大,但串起来就很典型:官方 SaaS 环境里的域名、HTTPS、OAuth、静态资源域、对象存储,本来就是一套整体;Self-hosted 只搬走 Core 镜像,却没有把这些外围假设一起搬走。

域名问题为什么特别难排

这次最消耗时间的是域名和 tenant 解析,因为它不是传统意义上的“一个域名指向一个 Web 服务”。

Afilmory 会根据请求 Host 推断 workspace,后台配置里又有 baseDomain,前端自己还会根据当前 hostname 计算 tenant slug。理论上这几层应该共享同一份配置,但自建时会出现三个现实问题:

  1. 后端没初始化前会使用项目自己的默认基础域名。
  2. 前端的一些规则是在构建时决定的,并不会实时读取数据库设置。
  3. 静态资源 Host 与业务 Host 又是另一套推导逻辑。

于是就可能出现这种很迷惑的状态:

TEXT
Core 已经启动
数据库正常
Root 用户也已经创建
/platform 的 HTML 甚至可以返回

但浏览器仍然看到:
404
空间不可用
空间访问受限
静态资源 404

排查这种系统不能只问“服务活没活”,还要继续问:

TEXT
这次请求的 Host 是什么?
后端把它解析成哪个 tenant?
前端认为 baseDomain 是什么?
静态资源又被改写到了哪个 Host?

最终我的处理原则很简单:自己的部署只保留自己的域名体系,官方域名只应该出现在源码历史里,不应该继续出现在运行时网络请求中。

不要只用 curl 测前端

这次还有一个很有代表性的坑:邮箱密码接口已经登录成功,但页面仍然提示“意外错误”。

如果只用 curl 看接口,完全会得出“后端没问题”的结论。真正的问题发生在浏览器继续执行前端逻辑时:HTTP 环境下调用 crypto.randomUUID() 失败。

这个问题最后是用真实浏览器环境才抓到的。

因此以后排查类似问题,我会把验证分成三层:

TEXT
第一层:curl / API
确认状态码、Cookie、JSON 是否正确。

第二层:浏览器 Network
确认重定向、资源请求、CORS、Mixed Content、Cookie Domain。

第三层:浏览器 Console
确认 Secure Context、Web API、运行时 JS 是否真的能继续执行。

“接口成功”只说明接口成功,不等于“用户流程成功”。

为什么最后选 RustFS,而不是硬改 Local Storage

照片本来就在 NAS 上,从直觉上看最简单的方案应该是:

TEXT
Afilmory -> /volume1/photos

但上游现在的存储抽象是围绕对象存储设计的。为了强行支持 local 去改 Core,会把改动深入到 storage provider、builder、URL 生成、权限和缩略图流程里,维护成本比想象中高。

因此最后换了一个思路:不改变 Afilmory 对存储的认知,只改变 NAS 暴露存储的方式。

TEXT
NAS 本地目录
    ↓
RustFS
    ↓ S3 API
Afilmory

对 Afilmory 来说它仍然在访问 S3;对我来说照片还是存在 NAS 本地磁盘上。

这类适配通常比 fork 上游的核心抽象更稳,因为升级时需要维护的只是部署边界,而不是业务代码的深层分支。

self-host-patch.sh 比手改容器重要得多

开始排障时很容易进入一个坏习惯:

TEXT
docker exec 进去
sed 一下
npm install 一下
刷新页面
好了

问题是容器一重建,所有东西都没了,而且过几天自己也很难解释“这个容器为什么能跑”。

后来所有自建修复都收进了 self-host-patch.sh,由启动流程自动执行。理想状态应该满足:

  • 每次容器启动都会检查。
  • 已经打过的补丁再次执行不会破坏文件。
  • 找不到预期代码时明确打印 Warning / Error。
  • 日志统一带 [afilmory-fix] 前缀。
  • 补丁失败时宁可暴露问题,也不要悄悄启动一个半坏状态的服务。

这会把“魔改容器”变成一层可审计的兼容层。

升级 Afilmory 后第一件事也不是看首页,而是看:

TEXT
[afilmory-fix] ...

每一步是否仍然成功匹配当前版本。

如果上游已经修复某个问题,则把对应补丁删掉,而不是让旧 patch 永远背在新版本身上。

资源 URL 尽量相对化

这次部署之后,我更确定一个原则:能使用相对 URL 的资源,就不要过早写成绝对 URL。

例如照片用:

TEXT
/afilmory/photos/xxx.webp

通常比:

TEXT
http://192.168.x.x:31842/photos/xxx.webp

更耐用。

相对 URL 自动继承当前页面:

  • 域名
  • HTTP / HTTPS
  • CDN
  • 反向代理
  • 端口策略

于是同一份数据可以同时适配:

TEXT
EdgeOne CDN
直连 HTTPS
局域网 HTTP
未来换域名

如果把协议、Host、端口全部提前写进数据库,后面的每一次网络架构调整都会变成一次数据迁移。

改动之前先想好怎么回滚

这次涉及数据库、tenant、域名绑定、账号和存储关系。越到后面越不能靠“先删了再说”。

最后保留下来的习惯是:

  1. 改数据库前先备份。
  2. 能新增就不覆盖。
  3. 能停用就先不删除。
  4. 域名、存储、账号绑定关系尽量保留原记录,确认新链路稳定后再清理。
  5. 每一步都要能明确回答“如果失败,怎么恢复到上一步”。

尤其是涉及管理员账号、tenant owner、数据库 migration 这些状态时,强行绕过权限保护往往只会把一个显性问题变成几个隐性问题。

如果某个删除操作被权限模型拦下,更合理的处理是先确认设计意图,再决定是否真的应该删除,而不是第一反应去 SQL 里硬删。

目前还要留意的事情

超级管理员入口

日常相册已经不依赖 Root 管理入口,但超级管理员账号和专门的 Root 域名还需要最后整理。

这里不应该继续复用部署过程中临时产生的密码。更稳妥的收尾方式是重新确认或重置超级管理员凭据,并决定是否真的需要给 Root Portal 单独配置 DNS。

升级风险

当前自建方案存在一层针对上游现状的 patch,因此升级不是简单的:

Shell
docker compose pull
docker compose up -d

每次更新后至少要检查:

  • sharp 是否已经被上游正式修复。
  • migration workaround 是否还需要。
  • 前端官方域名硬编码是否还存在。
  • 登录表单 patch 是否仍然匹配。
  • randomUUID 兼容 patch 是否还能安全应用。
  • 静态资源和照片路径有没有再次生成绝对域名。

如果启动日志中某一条 [afilmory-fix] 从“patched”变成“target not found”,要先看上游 diff,不要直接当作无害 Warning。

CDN 回源

部署过程中 EdgeOne 曾经使用 HTTP 31841 回源,最后切到了 HTTPS 31443。以后如果再次调整网关或证书,应该把 CDN 回源协议、直连协议和资源 URL 一起检查,而不是只测试首页。

安全收尾

部署排障阶段最容易留下临时凭据。最终应该统一做一次清理:

  • 轮换曾经出现在终端、聊天记录、临时脚本里的 NAS 和应用密码。
  • 删除本机 /tmp/rssh.sh、/tmp/rrun.sh 一类含明文凭据的临时脚本。
  • 清理 NAS 上已经不再使用的测试镜像。
  • 检查 Compose、启动脚本和 shell history 中是否残留 Secret。
  • 数据库密码、Auth Secret、对象存储密钥不要写进公开仓库,也不要写进这篇部署记录。

这次真正留下来的几条经验

如果把整个过程压缩成几句话,大概是这些:

  1. 官方有 Dockerfile,不代表它就是通用 Self-hosted 发行版。 先弄清楚它默认依赖了哪些官方 SaaS 基础设施。
  2. 域名是多租户系统的一部分业务状态,不只是 DNS。 Host、baseDomain、tenant、静态资源域必须一起看。
  3. 补丁必须代码化、幂等化。 临时进入容器手改,只适合验证猜想,不适合成为最终方案。
  4. 真实浏览器是 Web 系统测试的一部分。 curl 看不到 Secure Context、Mixed Content、前端 Web API 这类问题。
  5. 资源 URL 越晚绑定具体 Host 越好。 相对路径能显著降低 CDN、HTTPS、端口和域名迁移的耦合。
  6. 适配上游接口,通常比修改上游核心更容易长期维护。 RustFS 把本地 NAS 目录包装成 S3,就是一个很典型的例子。
  7. 所有危险改动都先考虑回滚。 能停用不删除,能备份不裸改,权限拦截不要硬绕。

最后

这次 Afilmory 最终能跑起来,并不是因为找到了某个“正确的 Compose 模板”,而是把官方 SaaS 环境里隐含的依赖逐层显式化了:

TEXT
域名
租户
认证
HTTPS
静态资源
对象存储
照片 URL
数据库迁移
运行时依赖

当这些边界都被自己掌握以后,Afilmory Core 反而重新变得简单:它只负责应用本身,外部访问交给 gateway,照片协议交给 RustFS,持久状态交给 PostgreSQL / Redis,公网分发交给 EdgeOne。

这也是这次折腾最值得留下来的东西:Self-hosted 不是把官方服务搬回家,而是把官方默认替换成自己能够解释、备份、回滚和长期维护的边界。