libSQL をセルフホストする — primary / replica レプリケーションと JWT 認証を Docker Compose で再現するデモ

重岡 正 ·  Tue, June 2, 2026

SQLite を分散・サーバ運用向けに拡張した libSQL は、Turso のマネージドサービスの裏側であると同時に、サーバ実装の sqld を使えば自前のインフラでもそのまま動かせます。primary に書き込み、replica で読む構成や、namespace によるマルチテナント、JWT 認証まで一通り揃っているのが魅力です。

ただ、いざ自前で立てようとすると、ドキュメントや過去記事と現在の挙動がずれている箇所にいくつもぶつかります。そこで、新しいクローンから ./scripts/smoke-test.sh 一発で全体を再現できるデモリポジトリを作りました。libsql-self-hosting-demo です。本記事では、その構成と、検証の過程で実際に確認できた「現在の挙動」を整理します。

下敷きにしたのは virtala.dev の libSQL セルフホスティング記事 です。2025-02-03 時点の Debian ベースの手順としてよくまとまっていますが、libSQL は動きが速く、当時の前提がそのまま通用しない箇所があります。デモでは同じアイデアを、現行の安定版である libsql-server v0.24.32 に対して検証し直しています。

デモの全体像

デモは Docker Compose で primary と replica の 2 サービスを立て、TypeScript の CLI から HTTP 経由でアクセスする構成です。

flowchart LR
    CLI[TypeScript CLI<br/>@libsql/client]
    subgraph compose[Docker Compose]
      P[primary<br/>HTTP 18080 / gRPC 18081<br/>Admin API 18082]
      R[replica<br/>HTTP 18083]
    end
    CLI -->|書き込み + 読み取り<br/>JWT| P
    CLI -->|読み取り<br/>JWT| R
    P -->|gRPC レプリケーション<br/>h2c| R
    Admin[Admin API<br/>namespace 作成] --> P

primary が書き込みを受け、replica は primary の gRPC エンドポイントに接続してデータを引いてきます。クライアントから見ると、書き込みは primary の HTTP、読み取りは primary でも replica でも、という素直な形です。host 側のポートはすべて 127.0.0.1 にバインドし、ローカル専用に閉じています。

サービス用途既定値
primaryHTTP API127.0.0.1:18080
primarygRPC レプリケーション127.0.0.1:18081
primaryAdmin API127.0.0.1:18082
replicaHTTP API127.0.0.1:18083

30008080 といった開発でよく使うポートは避け、衝突しにくい帯に寄せています。ポートは .env で上書きできるので、すでに埋まっている場合も docker-compose.yml を触らずに調整できます。

ランタイムの固定と Apple Silicon

最初に引っかかるのがイメージの選定です。参照記事が使っていた libsql/sqld リポジトリはすでに archived で、現行は tursodatabase/libsql 配下の libsql-serverghcr.io のイメージ)に統合されています。

再現性を最優先にしたいので、デモではイメージを floating タグではなく immutable な digest で固定しています。

x-libsql-image: &libsql-image ${LIBSQL_IMAGE:-ghcr.io/tursodatabase/libsql-server@sha256:528e068844b4bc5b87fb128da87e98d361d3414c4e1cced7b943939248e0ed2f}
x-libsql-platform: &libsql-platform ${LIBSQL_PLATFORM:-linux/amd64}

ここで Apple Silicon の問題が出ます。libsql-server には digest で固定できる arm64 のタグが存在せず(upstream issue #899)、arm64 を含むのは floating な latest-arm だけです。digest 固定と floating タグ禁止のルールを両立させるため、デモでは amd64 の digest を固定したうえで、Apple Silicon では platform: linux/amd64 を指定して Rosetta のエミュレーションで動かす判断にしました。ネイティブ arm64 は未検証として明記しています。

サーバの主要な設定は環境変数で渡します。primary と replica で差が出るのは次の 3 つです。

  • SQLD_DB_PATH: データの保存先
  • SQLD_GRPC_LISTEN_ADDR: primary だけが gRPC を待ち受ける
  • SQLD_PRIMARY_URL: replica だけが primary の gRPC を指す

replica は depends_oncondition: service_healthy を付け、primary の healthcheck が通ってから起動します。それでもレプリケーションには遅延があり得るので、後述のスモークテストでは即時可視性を仮定せず、replica の件数が揃うまでポーリングします。

JWT 認証

認証は Ed25519 の鍵ペアを使った JWT です。鍵生成は openssl に任せます。

openssl genpkey -algorithm Ed25519 -out .local/jwt/jwt.key
openssl pkey -in .local/jwt/jwt.key -pubout -out .local/jwt/jwt.pub

公開鍵を SQLD_AUTH_JWT_KEY_FILE でサーバに読み込ませ、クライアントは署名済みトークンを Authorization ヘッダで送ります。

トークンの署名だけは、シェルだけで base64url を組み立てて openssl pkeyutl -sign に渡す方式が壊れやすいので、小さな Node.js スクリプトに寄せました。scripts/gen-auth-token.sh はその薄いラッパで、実体は crypto モジュールを使う sign-jwt.mjs です。

const header = { alg: "EdDSA", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const payload = {
  a: "rw",
  iat: now,
  exp: now + 60 * 60 * 24,
};
 
const signingInput = `${base64url(JSON.stringify(header))}.${base64url(
  JSON.stringify(payload),
)}`;
const key = createPrivateKey(readFileSync(keyPath));
const signature = sign(null, Buffer.from(signingInput), key).toString("base64url");

クレームは開発用の最小構成で、{"a":"rw"}iat と 1 日後の exp を足しただけです。生成した鍵・トークンはあくまでローカル開発用の成果物で、本番で使えるものではありません。.gitignore で除外し、コミットされない設計にしています。

なお、Admin API 側の認証はこの JWT とは別系統で、--admin-auth-key で渡したキーを Authorization: basic local-admin-key の形で送ります。標準的な HTTP Basic 認証とは異なり、Base64 エンコードしない素のキーをそのまま載せる点に注意が必要です。

シナリオ 1: 既定 namespace のレプリケーション

一番素直で確実なのが、namespace を使わない既定構成でのレプリケーションです。primary に書いて replica で読めることを最初に確認し、土台を固めます。

CLI は @libsql/client を使った薄いものです。primary に書き込み、replica から読み出します。

pnpm --dir app start init   --target primary
pnpm --dir app start insert --target primary --label hello
pnpm --dir app start read   --target replica

クライアントの接続先は target に応じて切り替えるだけです。

export function endpointFor(target: DemoTarget, namespace?: string): string {
  const portEnv =
    target === "primary" ? "PRIMARY_HTTP_PORT" : "REPLICA_HTTP_PORT";
  const port = process.env[portEnv] ?? DEFAULT_PORTS[target];
  const host = namespace ? `${namespace}.localhost` : "127.0.0.1";
  return `http://${host}:${port}`;
}

この構成では、primary に挿入した行がしばらくして replica にも見えるようになります。レプリケーションは結果整合なので、スモークテストでは件数が揃うまでポーリングして待ちます。

シナリオ 2: primary のみの namespace

libSQL は namespace でマルチテナントを実現します。namespace を有効にするには --enable-namespaces が必要で、これは起動コマンドのフラグの違いなので、profiles: では切り替えられません。デモでは base の docker-compose.yml に対して docker-compose.namespaced.yml を重ねる方式にしました。

docker compose -f docker-compose.yml -f docker-compose.namespaced.yml up -d

namespace の作成・削除は Admin API を直接叩きます。

curl --fail-with-body --silent --show-error \
  -X POST "http://127.0.0.1:18082/v1/namespaces/demo/create" \
  -H "Authorization: basic local-admin-key" \
  -H "Content-Type: application/json" \
  --data '{}'

作成した namespace へのアクセスは、<namespace>.localhost という host ベースのルーティングで振り分けます。demo.localhost:18080 のようにサブドメインで namespace を指定する形です。macOS や多くのモダンブラウザは *.localhost127.0.0.1 に解決してくれるので、追加設定なしで動きます。

このシナリオでは、replica を絡めずに primary だけで次の 3 つを確認します。

  • 正しいトークンでアクセスできること(auth 成功)
  • 不正なトークンが弾かれること(auth 失敗)
  • 存在しない namespace がエラーになること

スモークテストでは、不正トークンや存在しない namespace が「成功してしまったら」逆に失敗扱いにして、ネガティブケースが本当に弾かれていることを担保しています。

シナリオ 3: namespace とレプリカの再検証

ここが今回いちばん確認したかった点です。Admin API で primary に作った namespace が、gRPC レプリカに伝播するのか。これは upstream issue #1804 として未解決のまま挙がっていて、「namespace はレプリカに伝播しない」という既知の制約とされています。

そこでデモでは、この振る舞いをビルドの成否に直結させず、実際の結果を記録する形にしました。namespace を作り、primary に書き込み、replica から読めるかを試します。

if run_cli count --target replica --namespace tenant >out 2>err; then
  echo "Namespace replica read succeeded: $(cat out)"
else
  echo "Namespace replica read did not succeed; expected for #1804."
fi

結果として、今回固定した v0.24.32 のイメージでは、namespace の replica 読み取りがローカルで 成功 しました(Namespace replica read succeeded: 1)。upstream の issue は未解決のままですが、少なくともこのバージョンでは伝播が観測できた、ということです。

このように「ドキュメント上は制約だが、特定バージョンでは動く」というあいまいな状態は珍しくありません。デモでは EXPECT_NAMESPACE_REPLICA という環境変数で期待値を切り替えられるようにして、既定では制約として扱いつつビルドは緑のままにし、pass を指定すれば「動かなければ失敗」に倒せるようにしています。将来のバージョンで挙動が変わっても、スモークテストがそれを表面化させてくれます。

gRPC TLS はオプション扱い

参照記事では証明書生成と相互 TLS が手順の中心に置かれていましたが、現行の replica は既定でプレーンテキストの gRPC(h2c)で primary に接続できます。一番壊れやすいステップをクリティカルパスから外したいので、デモでは TLS をオプションの付録に回しました。

証明書生成(scripts/gen-certs.sh)と相互 TLS は docker-compose.tls.yml を重ねたときだけ有効になります。

docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d

既定のフローは証明書を必要とせず、./scripts/setup.sh も証明書なしで完結します。生成する証明書は自己署名・短命の開発用で、これもコミット対象外です。

再現性のための足回り

このデモの主眼は「新しいクローンから誰でも同じ結果に辿り着けること」です。そのために、依存はすべて固定しています。

  • Docker イメージ: immutable な sha256 digest で固定し、floating タグは使わない
  • ローカルツール: mise で Node.js 26.3.0、pnpm 11.5.1、gitleaks 8.30.1、lefthook 2.1.9、shellcheck 0.11.0 を固定し、グローバルインストールに依存しない
  • npm 依存: package.json で exact version を指定し、pnpm-lock.yaml を integrity hash の出どころとしてコミットする

品質ツールも初期セットアップから入れています。gitleaks で鍵やトークンの誤コミットを防ぎ、shellcheck でシェルスクリプトを、Biome で TypeScript のフォーマットと lint を、tsc --noEmit で型チェックをかけます。lefthook で、これらの速いチェックをコミット前に走らせます。

GitHub Actions の CI は 2 ジョブ構成です。lint ジョブが gitleaks・shellcheck・Biome・tsc・frozen-lockfile install を回し、smoke ジョブが amd64 ランナー上でスモークテストを実行します。amd64 ランナーでは固定した digest がネイティブに動くので、Rosetta を介さずに 3 シナリオすべてを検証できます。

検証した環境やバージョン、digest は README.mddocs/notes.md に記録しています。

スモークテストとクリーンアップ

3 つのシナリオは scripts/smoke-test.sh にまとめてあり、各シナリオの前後でコンテナとボリュームを作り直して、毎回まっさらな状態から検証します。

./scripts/setup.sh
docker compose up -d
./scripts/smoke-test.sh

クリーンアップは、docker compose down -v --remove-orphans で base と namespaced の両構成のコンテナ・ボリュームを落としたうえで、生成したローカル状態 .local/ を消します。データディレクトリはコンテナ(root)が作るので、削除も同じイメージのコンテナ越しに行い、権限の食い違いを避けています。

./scripts/cleanup.sh

これで、クリーンアップ後にもう一度同じ手順を回せる状態に戻ります。

まとめ

このデモから引き出せる要点は以下です。

  • libsql/sqld は archived で、現行は tursodatabase/libsqllibsql-server に統合されている
  • arm64 の digest 固定可能なイメージは無く、Apple Silicon では amd64 digest を固定して Rosetta で動かすのが再現性との両立解になる
  • 既定 namespace のレプリケーションは確実に動く。namespace とレプリカの伝播(#1804)は制約とされるが、v0.24.32 ではローカルで成功した
  • gRPC TLS は現行ではオプションでよく、既定はプレーンテキストの h2c で繋がる
  • digest・ツール・npm 依存をすべて固定し、スモークテストで 3 シナリオを毎回まっさらから検証することで、新しいクローンから同じ結果に辿り着ける

セルフホストの一番の難所は、ドキュメントと現在の挙動のずれを 1 つずつ潰すところにあります。「動く構成」をスクリプトと digest ごと固めておくと、半年後の自分や他のメンバーが同じ落とし穴を踏まずに済みます。

以上、libSQL を自前で動かす最小構成を、再現可能な形に組み直している現場からお送りしました。

参考リンク