先说结论
这次最大的经验不是某一条 Docker 参数,而是:Afilmory 当前的官方构建更接近“把官方 SaaS 环境打成镜像”,而不是“开箱即用的通用 Self-hosted 镜像”。
把它直接搬到自己的 NAS,会连续撞上一批在官方环境里天然成立、在自建环境里却并不成立的默认假设:域名结构、租户识别、静态资源 Host、登录方式、HTTPS 安全上下文、对象存储,甚至数据库迁移和运行时依赖都可能需要自己接住。
前端尤其明显。有些域名行为是在构建阶段就决定的,afilmory.art 相关规则并不一定会随着后台的 System Settings 一起变化。结果就是:后台看起来已经改了,浏览器里跑的那份代码却还活在官方 SaaS 世界里。
最终真正稳定下来的思路不是继续“猜官方环境还缺哪个变量”,而是把它当成一个需要适配层的上游项目:上游镜像负责主体功能,自建环境通过可重复执行的补丁、统一网关和兼容存储把边界收回来。
最后的部署结构
部署过程中 CDN 回源方式调整过。早期试过 EdgeOne 通过 HTTP 31841 回源,最后改成了 HTTPS 回源 NAS 的 31443。HTTP 入口可以继续作为局域网调试或备用入口,但生产链路尽量统一到 HTTPS。
最终结构可以简化成:
┌──────────────────────────────┐
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。理论上这几层应该共享同一份配置,但自建时会出现三个现实问题:
- 后端没初始化前会使用项目自己的默认基础域名。
- 前端的一些规则是在构建时决定的,并不会实时读取数据库设置。
- 静态资源 Host 与业务 Host 又是另一套推导逻辑。
于是就可能出现这种很迷惑的状态:
Core 已经启动
数据库正常
Root 用户也已经创建
/platform 的 HTML 甚至可以返回
但浏览器仍然看到:
404
空间不可用
空间访问受限
静态资源 404排查这种系统不能只问“服务活没活”,还要继续问:
这次请求的 Host 是什么?
后端把它解析成哪个 tenant?
前端认为 baseDomain 是什么?
静态资源又被改写到了哪个 Host?最终我的处理原则很简单:自己的部署只保留自己的域名体系,官方域名只应该出现在源码历史里,不应该继续出现在运行时网络请求中。
不要只用 curl 测前端
这次还有一个很有代表性的坑:邮箱密码接口已经登录成功,但页面仍然提示“意外错误”。
如果只用 curl 看接口,完全会得出“后端没问题”的结论。真正的问题发生在浏览器继续执行前端逻辑时:HTTP 环境下调用 crypto.randomUUID() 失败。
这个问题最后是用真实浏览器环境才抓到的。
因此以后排查类似问题,我会把验证分成三层:
第一层:curl / API
确认状态码、Cookie、JSON 是否正确。
第二层:浏览器 Network
确认重定向、资源请求、CORS、Mixed Content、Cookie Domain。
第三层:浏览器 Console
确认 Secure Context、Web API、运行时 JS 是否真的能继续执行。“接口成功”只说明接口成功,不等于“用户流程成功”。
为什么最后选 RustFS,而不是硬改 Local Storage
照片本来就在 NAS 上,从直觉上看最简单的方案应该是:
Afilmory -> /volume1/photos但上游现在的存储抽象是围绕对象存储设计的。为了强行支持 local 去改 Core,会把改动深入到 storage provider、builder、URL 生成、权限和缩略图流程里,维护成本比想象中高。
因此最后换了一个思路:不改变 Afilmory 对存储的认知,只改变 NAS 暴露存储的方式。
NAS 本地目录
↓
RustFS
↓ S3 API
Afilmory对 Afilmory 来说它仍然在访问 S3;对我来说照片还是存在 NAS 本地磁盘上。
这类适配通常比 fork 上游的核心抽象更稳,因为升级时需要维护的只是部署边界,而不是业务代码的深层分支。
self-host-patch.sh 比手改容器重要得多
开始排障时很容易进入一个坏习惯:
docker exec 进去
sed 一下
npm install 一下
刷新页面
好了问题是容器一重建,所有东西都没了,而且过几天自己也很难解释“这个容器为什么能跑”。
后来所有自建修复都收进了 self-host-patch.sh,由启动流程自动执行。理想状态应该满足:
- 每次容器启动都会检查。
- 已经打过的补丁再次执行不会破坏文件。
- 找不到预期代码时明确打印 Warning / Error。
- 日志统一带
[afilmory-fix]前缀。 - 补丁失败时宁可暴露问题,也不要悄悄启动一个半坏状态的服务。
这会把“魔改容器”变成一层可审计的兼容层。
升级 Afilmory 后第一件事也不是看首页,而是看:
[afilmory-fix] ...每一步是否仍然成功匹配当前版本。
如果上游已经修复某个问题,则把对应补丁删掉,而不是让旧 patch 永远背在新版本身上。
资源 URL 尽量相对化
这次部署之后,我更确定一个原则:能使用相对 URL 的资源,就不要过早写成绝对 URL。
例如照片用:
/afilmory/photos/xxx.webp通常比:
http://192.168.x.x:31842/photos/xxx.webp更耐用。
相对 URL 自动继承当前页面:
- 域名
- HTTP / HTTPS
- CDN
- 反向代理
- 端口策略
于是同一份数据可以同时适配:
EdgeOne CDN
直连 HTTPS
局域网 HTTP
未来换域名如果把协议、Host、端口全部提前写进数据库,后面的每一次网络架构调整都会变成一次数据迁移。
改动之前先想好怎么回滚
这次涉及数据库、tenant、域名绑定、账号和存储关系。越到后面越不能靠“先删了再说”。
最后保留下来的习惯是:
- 改数据库前先备份。
- 能新增就不覆盖。
- 能停用就先不删除。
- 域名、存储、账号绑定关系尽量保留原记录,确认新链路稳定后再清理。
- 每一步都要能明确回答“如果失败,怎么恢复到上一步”。
尤其是涉及管理员账号、tenant owner、数据库 migration 这些状态时,强行绕过权限保护往往只会把一个显性问题变成几个隐性问题。
如果某个删除操作被权限模型拦下,更合理的处理是先确认设计意图,再决定是否真的应该删除,而不是第一反应去 SQL 里硬删。
目前还要留意的事情
超级管理员入口
日常相册已经不依赖 Root 管理入口,但超级管理员账号和专门的 Root 域名还需要最后整理。
这里不应该继续复用部署过程中临时产生的密码。更稳妥的收尾方式是重新确认或重置超级管理员凭据,并决定是否真的需要给 Root Portal 单独配置 DNS。
升级风险
当前自建方案存在一层针对上游现状的 patch,因此升级不是简单的:
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、对象存储密钥不要写进公开仓库,也不要写进这篇部署记录。
这次真正留下来的几条经验
如果把整个过程压缩成几句话,大概是这些:
- 官方有 Dockerfile,不代表它就是通用 Self-hosted 发行版。 先弄清楚它默认依赖了哪些官方 SaaS 基础设施。
- 域名是多租户系统的一部分业务状态,不只是 DNS。 Host、baseDomain、tenant、静态资源域必须一起看。
- 补丁必须代码化、幂等化。 临时进入容器手改,只适合验证猜想,不适合成为最终方案。
- 真实浏览器是 Web 系统测试的一部分。
curl看不到 Secure Context、Mixed Content、前端 Web API 这类问题。 - 资源 URL 越晚绑定具体 Host 越好。 相对路径能显著降低 CDN、HTTPS、端口和域名迁移的耦合。
- 适配上游接口,通常比修改上游核心更容易长期维护。 RustFS 把本地 NAS 目录包装成 S3,就是一个很典型的例子。
- 所有危险改动都先考虑回滚。 能停用不删除,能备份不裸改,权限拦截不要硬绕。
最后
这次 Afilmory 最终能跑起来,并不是因为找到了某个“正确的 Compose 模板”,而是把官方 SaaS 环境里隐含的依赖逐层显式化了:
域名
租户
认证
HTTPS
静态资源
对象存储
照片 URL
数据库迁移
运行时依赖当这些边界都被自己掌握以后,Afilmory Core 反而重新变得简单:它只负责应用本身,外部访问交给 gateway,照片协议交给 RustFS,持久状态交给 PostgreSQL / Redis,公网分发交给 EdgeOne。
这也是这次折腾最值得留下来的东西:Self-hosted 不是把官方服务搬回家,而是把官方默认替换成自己能够解释、备份、回滚和长期维护的边界。