我的伺服器上,「自己的資料庫」散落了一地:部落格、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。翻依賴就懂了:

bash
$ 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:

dockerfile
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 用路徑收進同一個網域:

yaml
# 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 同理
nginx
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 都不收。繞到這裡,我把問題拆到最原始的形狀做實驗:只把主檔設唯讀、目錄保持可寫會怎樣?

js
// 背景有個行程持續寫(模擬 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:

yaml
volumes:
  - /path/service-data:/data                            # 目錄 rw:給 -shm
  - /path/service-data/db.sqlite:/data/db.sqlite:ro     # 主檔唯讀:擋寫入

進容器實測,連 root 都寫不進去——檔案系統層()+ SQLite 層(SQLITE_READONLY)雙重擋:

bash
$ 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 引擎)對著同一個檔狂寫五秒:

text
# 第一輪(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_checkok。最壞情況只是某次寫入要等鎖,不會壞檔。

WARNING

實測證偽了「WAL 會被寫壞」之後,真正的風險反而浮出來:手滑改壞 live 資料。Outerbase 是直接改線上的庫、沒有 undo。所以最終配置是——web / Jarvis / NAS 這些我自己的可編輯,n8n / Kuma / 3x-ui 一律 live 唯讀,按下儲存就跳 SQLITE_READONLY。危險的從來不是引擎併發,是那雙會手滑的手。

Mongo:兩道牆疊在一起#

另一個專案有一個 MongoDB,我想用 Mongoku(現代 web 版的 Compass)。這裡有兩道牆疊在一起:

  1. mongod 只綁 127.0.0.1
  2. 這台的防火牆把所有「容器 → host」的新連線都丟掉

第二點我是這樣確診的——在 host 上自己連是通的,但任何容器(連 Docker 官方的 host.docker.internal)都 timeout:

bash
# 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_:

yaml
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:

nginx
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」當成「做完了」。先驗證,再動手。