🔑 关键洞察
✦ AI·GEN这篇文章记录了作者如何把散落在 homelab 各处的资料库——六个分属部落格、n8n、Uptime Kuma、3x-ui、Jarvis、NAS 的 SQLite,外加一个 MongoDB——整合成一个从任何地方都能存取、且挡着 2FA 的网页后台。文章先从选型讲起:为什么不为了换而换上 Postgres、如何在 sqlite-web、Adminer、Beekeeper、CloudBeaver 之间绕了一圈才落在 Outerbase Studio,以及对外闸门为何舍 Cloudflare Tunnel 而自架 Authelia。接着逐一拆解建置时的坑:官方 Docker image 读不了本地档(只有 npm CLI 能)、ATTACH 看得到资料却看不到表、WAL 模式无法唯读挂载(最后靠「主档唯读、目录可写」两行解决)、libSQL 与标准 SQLite 并发写入的实测、容器连不到 host 上的 mongod、以及 Service Worker 把资料库子网域劫持成部落格的鬼打墙。最后用 Authelia 的 forward-auth 把整批服务罩在同一道 2FA 之后,并反思这些坑没一个真的难——难的是太早把「容器起来了、回 200」当成「做完了」。
我的伺服器上,「自己的资料库」散落了一地:部落格、n8n、Uptime Kuma、3x-ui、Jarvis、NAS 各一份 SQLite,另一个专案那边还有一个 MongoDB。每次要看点资料,不是 ssh 进容器敲 sqlite3,就是临时开个 web,或在笔电再装一次 Compass。
真正的痛,其实不是 SQLite 不好,而是它就是一个藏在伺服器里的档案,很难即时看、更难顺手改。
目标很单纯:一个网页后台,从任何地方都能看、能改,而且要有 2FA。 但这个「单纯」的需求,牵出一整串 Docker、SQLite WAL、libSQL、防火墙与 Service Worker 的细节。这篇把每个坑跟它的解法都写下来——包括我在选型上绕的那些远路。
先别急着把 SQLite 换成 Postgres#
第一个岔路不是工具,是要不要干脆换掉 SQLite。我一度觉得「是不是该上 Postgres 了」,还去查 Innei 用什么——结果他是 MongoDB + Redis,但那是因为他的后台是给很多人装的文件型 CMS,跟我这种单机、单后端、读多写极少的个人站完全两回事,不是我该照抄的理由。
认真盘过一轮:对我来说,SQLite 是正解,不是妥协。唯一值得换 Postgres 的理由,是哪天要做 AI 语意搜寻、需要 pgvector——而那种等级的改动,应该跟「后端整包重写成 Rust」绑在一起做,不是现在为了换而换。
所以问题被我重新定义了:痛点不是 SQLite,是「一个藏在档案里的资料库很难即时看/改」。这该用工具解,不是换资料库。
找一个好看、又开得了远端档的工具#
需求两条:介面要好看(我对丑工具没耐性),而且要能开伺服器上的本地档。这两条加在一起,意外地难:
- VS Code 的 SQLite 扩充:我现在在用,但只能看、不能顺手改,就是这个痛点的起点。
- sqlite-web / Adminer:一个容器就能跑、什么都吃,但那股阳春的 Flask / PHP 工具感,我看一眼就关掉。
- Beekeeper Studio / TablePlus:桌面派介面漂亮,但我的 SQLite 是远端的档案,桌面工具得先 sftp 或同步下来,很卡。
- CloudBeaver(DBeaver 的 web 版):server 端 Java、内建 JDBC SQLite driver,能直接开伺服器上的档、WAL 多进程也安全——技术上其实最稳,我一度差点就用它。
最后我还是想要 Outerbase Studio,因为它是这里面最现代、最顺眼的一个(有点 Notion 感)。结果拉了官方 Docker image,它读不到伺服器上的 .sqlite。翻依赖就懂了:
$ docker run --rm --entrypoint cat outerbase/studio /app/package.json | grep -iE "sqlite|libsql"
"@libsql/client": "^0.5.3"只有 @libsql/client,没有 better-sqlite3 这种能在伺服器端开本地档的东西——这个 image 是 browser-first 的,设计上连的是 Turso / libSQL / D1 那类网路资料库,「本地档」指的是靠浏览器去开你自己电脑上的档。
我差点就下结论「Outerbase 做不到、改用 CloudBeaver」——直到我去翻它的 npm。它另外发了一个 CLI @outerbase/studio,studio <path> 会在本机起服务、直接开伺服器上的档,甚至内建 basic auth。所以我包一个极小的容器只跑这支 CLI:
FROM node:20-alpine
RUN npm install -g @outerbase/studio@0.2.7 # 钉版本,不让 runtime 抓 latest
ENTRYPOINT ["studio"]TIP
这里的教训很直接:别只看一个产物(官方 Docker image)就替整个工具下定论。 同一个工具的 npm CLI 能做的事,跟它的 Docker image 完全是两回事。我绕去 CloudBeaver 又绕回来,就是太早把「image 读不了」当成「这工具不行」。
一个库,一个实例#
SQLite 有 ATTACH DATABASE,理论上能把六个 DB 挂进同一条连线一次看完。我也真的接起来了,但 Outerbase 的表浏览器只认主库的 schema——attach 进来的五个库,资料查得到(SELECT * FROM 别名.表),但侧栏一张表都不显示。
所以改成一个 DB 一个实例,各自用 --base-path,再让 nginx 用路径收进同一个网域:
# docker-compose.yml(节录)
ob-web: { command: ["/data/db.sqlite", "--port","4000","--base-path","/web"] }
ob-n8n: { command: ["/data/database.sqlite","--port","4002","--base-path","/n8n"] }
ob-kuma: { command: ["/data/kuma.db", "--port","4003","--base-path","/kuma"] }
# …xui / jarvis / nas 同理location /web/ { proxy_pass http://127.0.0.1:4000; }
location /n8n/ { proxy_pass http://127.0.0.1:4002; }
location /kuma/ { proxy_pass http://127.0.0.1:4003; }六个实例挤在同一个 db.koimsurai.com 底下(根路径自动转到 /web)。我懒得每次手记哪个路径对哪个库,所以又做了一张自己写的深色卡片选单页当入口,六张卡各进一个 DB。为确认这个 SPA 在 base-path 下不会因为资产路径挂掉,我用 headless 浏览器实际跑过、确认完整渲染才收工。
唯读:绕了一大圈才找到的两行#
n8n、Kuma、3x-ui 这些第三方服务,表结构我看不懂,我只想看、不想手滑改坏。这条路我走了两段冤枉路。
第一段是 snapshot:写一个 sidecar,每五分钟用 sqlite3 .backup 产一份副本,让 Outerbase 去读副本。结果 .backup 产出的副本继承了来源的 WAL 模式,副本自己也是 WAL、照样唯读开不了,得再补一句 PRAGMA journal_mode=DELETE 转成非 WAL 才行。但更根本的问题是——这时看到的是副本、不是即时资料,跟我一开始「要能即时看」的需求根本对不上。snapshot 整条放弃。
第二段才是正题:直接把档案挂成唯读。最直觉的 docker :ro 一开就炸:
LibsqlError: SQLITE_CANTOPEN: unable to open database file
原因在 。WAL 模式连读取都需要一个共享记忆体索引 ,整个档案系统设唯读,-shm 建不出来就开不了。我接着试在连线字串塞 SQLite 的唯读参数,@libsql/client 直接拒收:
LibsqlError: URL_PARAM_NOT_SUPPORTED: Unsupported URL query parameter "mode"
?mode=ro、?immutable=1 都不收。绕到这里,我把问题拆到最原始的形状做实验:只把主档设唯读、目录保持可写会怎样?
// 背景有个行程持续写(模拟 app 在跑、-shm 活着);主档已 chmod 444
const db = createClient({ url: "file:/tmp/w.db" });
await db.execute("SELECT count(*) FROM t"); // → 读到 56 笔(含背景刚写的)
await db.execute("INSERT INTO t(v) VALUES('x')");
// → SQLITE_READONLY: attempt to write a readonly database成了。原理是:SQLite 用「主资料库档能不能写」决定整条连线是否唯读。 主档唯读 → 连线唯读、写入一律拒;而 -shm / -wal 在可写目录里,所以照样读得到 WAL 的即时资料。翻成 docker 就两行——目录 rw、主档叠一层 :ro:
volumes:
- /path/service-data:/data # 目录 rw:给 -shm
- /path/service-data/db.sqlite:/data/db.sqlite:ro # 主档唯读:挡写入进容器实测,连 root 都写不进去——档案系统层()+ SQLite 层(SQLITE_READONLY)双重挡:
$ docker exec ob-n8n sh -c 'dd if=/dev/zero of=/data/database.sqlite bs=1 count=1'
dd: can't open '/data/database.sqlite': Read-only file system可写的库,会不会被写坏#
web、Jarvis、NAS 是我自己的,挂成可写。但 libSQL 这个引擎跟 app 用的标准 SQLite 同时写同一个 WAL 档,会不会打架打坏档?不想用猜的,直接拿两个行程(Python 内建 sqlite3 当「标准引擎」、@libsql/client 当 Outerbase 引擎)对着同一个档狂写五秒:
# 第一轮(libSQL 无 busy_timeout)
标准 sqlite3 写入: 24803 笔
libSQL: 0 笔 — SQLITE_BUSY: database is locked
integrity_check: ok
# 第二轮(libSQL 加 busy_timeout=8000)
两边都写入
integrity_check: ok关键是第一轮的 SQLITE_BUSY:它代表 libSQL 看得见也尊重标准 SQLite 上的锁,是在排队、不是绕过去各写各的(libSQL 有「Virtual WAL」自订介面,原本让我有点怕);两轮 integrity_check 都 ok。最坏情况只是某次写入要等锁,不会坏档。
WARNING
实测证伪了「WAL 会被写坏」之后,真正的风险反而浮出来:手滑改坏 live 资料。Outerbase 是直接改线上的库、没有 undo。所以最终配置是——web / Jarvis / NAS 这些我自己的可编辑,n8n / Kuma / 3x-ui 一律 live 唯读,按下储存就跳 SQLITE_READONLY。危险的从来不是引擎并发,是那双会手滑的手。
Mongo:两道墙叠在一起#
另一个专案有一个 MongoDB,我想用 Mongoku(现代 web 版的 Compass)。这里有两道墙叠在一起:
mongod只绑127.0.0.1。- 这台的防火墙把所有「容器 → host」的新连线都丢掉。
第二点我是这样确诊的——在 host 上自己连是通的,但任何容器(连 Docker 官方的 host.docker.internal)都 timeout:
# host 直连:通
$ mongosh "mongodb://USER:PASS@127.0.0.1:27017/?authSource=admin" --eval "db.adminCommand('listDatabases')"
→ 顺利列出资料库清单
# 容器连同一位址:timeout(被防火墙丢掉)
$ docker run --rm --network db-admin_default mongo:7 mongosh "mongodb://…@172.17.0.1:27017/…"
→ MongoServerSelectionError: connect timed out我还试过架一个 socat relay 中继,一样不通——因为 Mongoku 在 db-admin_default 网段、socat 绑在 default bridge,跨 Docker 网路被隔离。加上 Mongoku 的 image 把监听 port 写死 3100,而我那台 3100 已经被别的 next-server 占走,设 PORT 环境变数还没用。
转机有两个。第一,host-network 的容器走的是 host 自己的 loopback,不经过那道挡容器的防火墙——所以让 Mongoku 用 network_mode: host,直接连 127.0.0.1:27017。第二,PORT 没用是因为它是 SvelteKit 打包的,环境变数前缀得是 MONGOKU_SERVER_:
mongoku:
image: huggingface/mongoku:latest
network_mode: host
environment:
- MONGOKU_SERVER_HOST=127.0.0.1 # 只绑 loopback,不外露
- MONGOKU_SERVER_PORT=4001 # 避开被占的 3100
- MONGOKU_SERVER_ORIGIN=http://127.0.0.1:4001 # 要能写入才需要
- MONGOKU_DEFAULT_HOST=mongodb://USER:PASS@127.0.0.1:27017/?authSource=admin打开,它乖乖列出那台 Mongo 的资料库。
被 Service Worker 绑架的子网域#
接好之后冒出一个鬼打墙:进 db.koimsurai.com 有时候直接跑出我的部落格。我第一时间误判成 port 80 被 blog 的 wildcard 接走,查了半天不对。
真凶是 。这个子网域在变成资料库后台之前,我曾经用它连过部落格;而部落格是 PWA,它的 service worker 注册在 db.koimsurai.com 这个 origin、快取了整个 app shell,于是我一导览过去,它就用快取把部落格「抢答」出来了。Ctrl + Shift + R 略过 service worker 就正常。根治的话,清掉该站的 site data、或在旧路径放一个会自我注销的 sw.js。这个坑跟资料库一点关系都没有,纯粹是浏览器替我保留了太久的记忆。
一道 2FA,罩住全部#
最后是对外的锁。我的条件很明确:有 URL 能进、但光帐密不够保险、要有 2FA,而且我不想搞 WireGuard 那种内网连法。
第一个被推荐的是 Cloudflare Tunnel + Access:零 inbound port 暴露、边缘就有 passkey、免费。技术上很漂亮,但我卡在一个直觉——所有流量都绕去 Cloudflare,不会卡吗? 加上我其实想要自架的掌控感,最后选了自架的 Authelia(Tailscale、Authentik 也都看过,前者要装 client、我不习惯 VPN)。心态是:「开 port 也没差,被扫到他也连不进来;哪天设定错把自己锁在外面,也是我自己的事。」
Authelia 用 把整批罩起来,nginx 进任何一个 DB 后台前,都先过 Authelia 的登入 + passkey:
auth_request /internal/authelia/authz;
auth_request_set $redirect $scheme://$http_host$request_uri;
error_page 401 =302 https://auth.koimsurai.com/?rd=$redirect;设定时有两个小地方值得记:一,Authelia 预设要你设 SMTP 寄确认信——但都自架了还要一台 SMTP 很怪,所以改用 notifier.filesystem,确认信直接写成本地档,第一次注册 2FA 就 docker exec authelia cat /config/notification.txt 把连结挖出来。二,它的 TOTP 存在自己的 SQLite 里、以 username 当 key,所以后来我把帐号从 timo 改成 timo9378 时,得连那张表一起改名,否则 OTP 对不上、只能重新注册。
Authelia 我刻意拆成独立的一包(自己的 repo),之后任何服务要加 2FA,只要在 nginx 指一段过去就行,完全插拔式。三个子网域 auth. / db. / mongo. 的 A record 都设 DNS-only(不走 Cloudflare 橘云代理),凭证直接重用既有的 wildcard *.koimsurai.com,子网域不用另签。
先验证,再动手#
最终的样子:六个 SQLite(自己的可改、第三方唯读、全部即时)加一个 Mongo,全部收在同一个网域、同一道 2FA 后面。
回头看,这些坑没一个是真的难。Outerbase 的 CLI、ATTACH 的限制、WAL 的唯读、容器连不到 host、被 service worker 绑架的子网域——每个答案都只隔着「先把它的真实行为弄清楚」这一步。我绕远路,几乎都是因为太早把「容器跑起来了、回 200」当成「做完了」。先验证,再动手。
还没有留言
✨ 成为第一个留言的人吧