サーバー上で「自分のデータベース」があちこちに散らばっています。ブログ、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 を読めません。依存を見れば理由は明らか:

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 のようなネットワーク DB に繋ぐもの。「ローカルファイル」とはブラウザ経由で開く自分の PC 上のファイルを指します。

「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 が読めない」を「このツールは無理」と早合点したから。

一つの DB に、一つのインスタンス#

SQLite には ATTACH DATABASE があり、理論上は六つの DB を一つの接続にぶら下げて一気に見られます。実際に繋いでみたのですが、Outerbase のテーブルブラウザはメイン DB のスキーマしか認識しません。 attach した五つの DB はクエリ(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 か覚えるのが面倒なので、自作のダークなカードメニューをトップに置き、六枚のカードがそれぞれ一つの 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 も受け付けない。ここで詰まり、問題を一番素朴な形に落として実験しました——メインファイルだけ読み取り専用にして、ディレクトリは書き込み可能なら?

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 は「メイン DB ファイルが書けるか」で接続全体が読み取り専用かを決める。 メインファイルが読み取り専用なら接続も読み取り専用、書き込みは一律拒否。一方 -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

書き込み可能な DB は壊れない?#

web、Jarvis、NAS は自分のものなので書き込み可能でマウントします。でも libSQL エンジンと app が使う標準 SQLite が同じ WAL ファイルを同時に書く——ぶつかって壊さないか? 推測ではなく、二つのプロセス(Python 内蔵の sqlite3 を「標準エンジン」、@libsql/client を Outerbase エンジンとして)で同じファイルを 5 秒間たたいて検証しました:

text
# 第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)を使いたかった。ここには壁が二枚重なっています:

  1. mongod127.0.0.1 にしかバインドしていない。
  2. このホストのファイアウォールが**「コンテナ → ホスト」の新規接続をすべて落とす。**

二つ目はこう診断しました——ホストから繋ぐと通るのに、どのコンテナ(Docker 純正の host.docker.internal を含む)もタイムアウトする:

bash
# ホストから直接:通る
$ 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 out

socat リレーを立ててみても通らない——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_ でなければならないから:

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 がブログの 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 を通します:

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 をキーに保存されるので、後でアカウントを 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 を返した」を早々に「完了」と見なしたから。まず検証、それから組む。