🔑 重要なポイント
✦ AI·GENhomelab に散らばったデータベース——ブログ・n8n・Uptime Kuma・3x-ui・Jarvis・NAS の六つの SQLite に MongoDB 一つ——を、どこからでも開けて 2FA を前に置いた一つの web コンソールへ統合するまでの記録。まず選定から:なぜ「替えるために」Postgres へ移らないのか、sqlite-web・Adminer・Beekeeper・CloudBeaver を一周して最後に Outerbase Studio へ落ちた経緯、対外ゲートを Cloudflare Tunnel ではなく自前の Authelia にした理由。続いて構築時の落とし穴を一つずつ:公式 Docker image はローカルファイルを読めない(npm CLI だけが読める)、ATTACH はデータは見えるがテーブルは見えない、WAL は読み取り専用マウントできない(最後は「メインファイル読み取り専用・ディレクトリ書き込み可」の二行で解決)、libSQL と標準 SQLite の並行書き込みの実測、ホストの mongod に届かないコンテナ、そして Service Worker がデータベースのサブドメインをブログにすり替える怪。最後に forward-auth で全体を一つの Authelia 2FA ゲートの後ろに収め、どの落とし穴も本当は難しくなく——難しいのは「コンテナが起動して 200 を返した」を早々に「完了」と見なすことだった、と振り返る。
サーバー上で「自分のデータベース」があちこちに散らばっています。ブログ、n8n、Uptime Kuma、3x-ui、Jarvis、NAS がそれぞれ SQLite を持ち、別のプロジェクトにはさらに MongoDB が一つ。データをちょっと見たいたびに、コンテナに ssh して sqlite3 を叩くか、使い捨ての web を立てるか、ノート PC にもう一度 Compass を入れるか——という具合でした。
本当の痛みは SQLite が悪いことではなく、それがサーバーの中に埋もれた一つのファイルで、ライブで覗くのが難しく、ましてや気軽に直すのはもっと難しいことでした。
やりたいことはシンプルでした。どこからでも開けて、見られて、編集もでき、しかも前に 2FA がかかっている管理画面が一つ欲しい。 ところがこの「シンプル」が、Docker・SQLite の WAL・libSQL・ファイアウォール・Service Worker にまつわる細かい話を芋づる式に引っ張り出してきました。この記事では、踏んだ落とし穴とその抜け方を——ツール選びで回り道した分も含めて——一つずつ書き留めます。
SQLite を Postgres に急いで替えない#
最初の岐路はツールではなく、いっそ SQLite をやめるかどうかでした。「Postgres にすべきか?」と一度は考え、Innei が何を使っているかも調べました——結果は MongoDB + Redis。でもそれは彼のバックエンドが大勢がインストールする文書型 CMS だからで、単機・単バックエンド・読み多書き極少の僕の個人サイトとは全く別世界。真似する理由にはなりません。
しっかり棚卸しした結論:僕にとって SQLite は妥協ではなく正解。Postgres に替える唯一の価値ある理由は、いつか AI の意味検索で pgvector が要るとき——そしてその規模の変更は「バックエンドを丸ごと Rust に書き直す」とセットでやるべきで、今、替えるために替えるものではありません。
だから問題を定義し直しました:痛点は SQLite ではなく「ファイルに埋もれたデータベースはライブで見/直しにくい」こと。これはツールで解く、DB を替えるのではない。
見た目が良くて、しかもリモートのファイルを開けるツールを探す#
条件は二つ:UI がきれいなこと(醜いツールに耐性がない)、そしてサーバー上のローカルファイルを開けること。この二つが重なると、意外と難しい:
- VS Code の SQLite 拡張:今使っているが、見るだけで編集が不便——これが痛点の起点。
- sqlite-web / Adminer:一つのコンテナで動き何でも食うが、あの素朴な Flask / PHP のツール感で一目で閉じてしまう。
- Beekeeper Studio / TablePlus:デスクトップ勢は UI が美しいが、僕の SQLite はリモートのファイルで、デスクトップツールはまず sftp か同期で落としてくる必要があり、もたつく。
- CloudBeaver(DBeaver の web 版):サーバー側 Java、JDBC SQLite driver 内蔵で、サーバー上のファイルを直接開け、WAL のマルチプロセスも安全——技術的には一番堅く、危うくこれにするところでした。
それでも僕は Outerbase Studio が欲しかった。この中で一番モダンで一番目に優しい(少し Notion 感がある)からです。ところが公式 Docker image を pull しても、サーバー上の .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 のようなネットワーク DB に繋ぐもの。「ローカルファイル」とはブラウザ経由で開く自分の PC 上のファイルを指します。
「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 が読めない」を「このツールは無理」と早合点したから。
一つの DB に、一つのインスタンス#
SQLite には ATTACH DATABASE があり、理論上は六つの DB を一つの接続にぶら下げて一気に見られます。実際に繋いでみたのですが、Outerbase のテーブルブラウザはメイン DB のスキーマしか認識しません。 attach した五つの DB はクエリ(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 か覚えるのが面倒なので、自作のダークなカードメニューをトップに置き、六枚のカードがそれぞれ一つの DB へ入るようにしました。この SPA が base-path 下でアセットパスの問題で壊れないことを確かめるため、ヘッドレスブラウザで実際に動かし、完全に描画されるのを確認してから仕上げました。
読み取り専用:大回りの末の二行#
n8n、Kuma、3x-ui のような第三者サービスは、テーブル構造が分からないので見るだけにして、手が滑って壊したくない。この道は二回、無駄足を踏みました。
一段目は snapshot:五分ごとに sqlite3 .backup で副本を作り、Outerbase に副本を読ませる sidecar。ところが .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 は「メイン DB ファイルが書けるか」で接続全体が読み取り専用かを決める。 メインファイルが読み取り専用なら接続も読み取り専用、書き込みは一律拒否。一方 -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書き込み可能な DB は壊れない?#
web、Jarvis、NAS は自分のものなので書き込み可能でマウントします。でも libSQL エンジンと app が使う標準 SQLite が同じ WAL ファイルを同時に書く——ぶつかって壊さないか? 推測ではなく、二つのプロセス(Python 内蔵の sqlite3 を「標準エンジン」、@libsql/client を Outerbase エンジンとして)で同じファイルを 5 秒間叩いて検証しました:
# 第1ラウンド(libSQL は busy_timeout なし)
標準 sqlite3 の挿入: 24803 件
libSQL: 0 件 — SQLITE_BUSY: database is locked
integrity_check: ok
# 第2ラウンド(libSQL に busy_timeout=8000)
両方とも挿入できた
integrity_check: ok肝は第1ラウンドの SQLITE_BUSY。これは libSQL が標準 SQLite のロックを見ているし尊重している証拠で、順番待ちをしているのであって、勝手に並走して書いているわけではない(libSQL には「Virtual WAL」という自作インターフェースがあり、それが最初の不安の種でした);そして integrity_check は二回とも ok。最悪でも書き込みがロック待ちになるだけで、ファイルは壊れません。
WARNING
「WAL が壊れる」を検証で否定した後、真のリスクが逆に浮上しました:ライブデータを手が滑って壊すこと。 Outerbase はライブの DB を直接編集し、undo はありません。なので最終構成は——web / Jarvis / NAS(自分の)は編集可、n8n / Kuma / 3x-ui は ライブ読み取り専用で、保存を押した瞬間に SQLITE_READONLY。危険なのはエンジンの並行性ではなく、滑る手のほうでした。
Mongo:二枚の壁が重なる#
別のプロジェクトに MongoDB が一つあり、Mongoku(モダンな web 版 Compass)を使いたかった。ここには壁が二枚重なっています:
mongodが127.0.0.1にしかバインドしていない。- このホストのファイアウォールが**「コンテナ → ホスト」の新規接続をすべて落とす。**
二つ目はこう診断しました——ホストから繋ぐと通るのに、どのコンテナ(Docker 純正の host.docker.internal を含む)もタイムアウトする:
# ホストから直接:通る
$ mongosh "mongodb://USER:PASS@127.0.0.1:27017/?authSource=admin" --eval "db.adminCommand('listDatabases')"
→ データベース一覧をきちんと返す
# コンテナから同じアドレス:タイムアウト(ファイアウォールに落とされる)
$ docker run --rm --network db-admin_default mongo:7 mongosh "mongodb://…@172.17.0.1:27017/…"
→ MongoServerSelectionError: connect timed outsocat リレーを立ててみても通らない——Mongoku は db-admin_default ネットワークにいて、socat は default bridge にバインドされ、Docker ネットワーク越しに隔離されているからです。おまけに Mongoku の image は待ち受けポートを 3100 にハードコードしていて、僕の環境では 3100 が別の next-server に取られ、PORT を設定しても効かない。
突破口は二つ。第一、host-network のコンテナはホスト自身の 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 がブログの wildcard に取られたと誤診し、しばらく追いましたが、違いました。
真犯人は 。このサブドメインがデータベース管理画面になる前、僕は一度これでブログを開いたことがあった;ブログは PWA なので、その service worker が db.koimsurai.com というオリジンに登録され、app shell をまるごとキャッシュしていた——だから移動した瞬間、キャッシュでブログを「先取り」して出してきたのです。Ctrl + Shift + R で service worker を飛ばせば正常。根治するなら、そのサイトの site data を消すか、旧パスに自己登録解除する sw.js を置く。この落とし穴はデータベースとは何の関係もなく、純粋にブラウザが記憶を長く持ちすぎただけでした。
一つの 2FA で、全部を覆う#
最後は外側の鍵。条件は明確でした:URL で入れるが、ID/パスワードだけでは安全でない——2FA が欲しい、そして WireGuard 的な内部網はやりたくない。
最初に勧められたのは Cloudflare Tunnel + Access:inbound ポート露出ゼロ、エッジで passkey、無料。技術的には美しいのですが、直感で引っかかりました——全トラフィックが Cloudflare を経由して、重くならないか? それに自前で持つ手応えも欲しくて、最終的に自前の Authelia を選びました(Tailscale や Authentik も見たが、前者はクライアントが要り、僕は VPN に慣れていない)。心持ちは:「ポートを開けても構わない——スキャンされても入れないし、設定を間違えて自分を締め出しても、それは自分の問題」。
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 をキーに保存されるので、後でアカウントを timo から timo9378 に変えたとき、そのテーブルも一緒に改名しないと OTP が合わず、再登録になります。
Authelia はわざと独立した一塊(専用の repo)にしてあるので、今後 2FA を足したいサービスは nginx を一区画向けるだけ——完全に差し込み式。三つのサブドメイン auth. / db. / mongo. の A レコードはすべて DNS-only(Cloudflare のオレンジ雲プロキシを通さない)で、証明書は既存のワイルドカード *.koimsurai.com をそのまま再利用——サブドメインに別途発行は要りません。
まず検証、それから組む#
最終形:六つの SQLite(自分のは編集可、第三者は読み取り専用、すべてライブ)に Mongo 一つ、すべてを一つのドメインと一つの 2FA ゲートの後ろに。
振り返ると、どの落とし穴も本当に難しいものは一つもありませんでした。Outerbase の CLI、ATTACH の制限、WAL の読み取り専用、ホストに届かないコンテナ、service worker に乗っ取られたサブドメイン——どの答えも「まずそれが実際どう動くかを調べる」という一歩の先にあっただけ。遠回りをしたのは、ほぼ全部「コンテナが起動して 200 を返した」を早々に「完了」と見なしたから。まず検証、それから組む。
まだコメントがありません
✨ 最初のコメントを残しませんか