Cloudflare D1 で個人開発の最小データベースを作る手順

Cloudflare Workers で fetch ハンドラを 1 つデプロイすると、もう「サーバーが立たない」という詰まりは個人開発の進行を止めなくなります。次に欲しくなるのは、データの居場所です。フォームの保存、ユーザー設定、ちょっとした分析テーブル ── これらは Worker のメモリには残せません(リクエストをまたいで消える)。

このとき登場するのが、Cloudflare D1。Cloudflare のサーバーレス SQL データベースで、エンジンは SQLite。Worker と同じインフラに同居して、env.DB という bindings で 1 行から呼べます。本記事では、D1 に 個人開発の最小テーブルを 1 つ作って、Worker から読み書きするまで を、wranglerd1 create → migrations apply → execute で通します。

Workers の最小デプロイは姉妹記事 Cloudflare Workers で最小の API を公開する手順 で扱いました。本記事はその続編 ── 「API は動いた、次はデータ」 の地続きで読めます。

D1 とは(KV / R2 との違い)

Cloudflare のデータストアは 3 系統あって、用途で使い分けます。

D1KVR2
データモデルリレーショナル(SQLite)Key-Valueオブジェクトストレージ
向く用途テーブル・JOIN・トランザクション設定・キャッシュ・セッション画像・動画・大きいファイル
一貫性強い(リーダー書き込み)結果整合(数十秒で世界に伝播)強い(書き込み確定で読める)
無料枠(2026 年現在)5GB/5M rows/day read/100k rows/day write100k read/day・1k write/day・1GB10GB・100 万クラス A 操作/月
個人開発の体感普通の SQL を書きたい時の第一選択短命データの保存場所静的アセットの倉庫

ユーザーとフォーム回答を結びつけて検索したい」「ID で 1 行取りたい」「集計クエリを書きたい」── このどれかが要るなら、迷わず D1 です。本記事は D1 単独 の最小経路で進めます。R2 は別記事で扱います。

手順 1:wrangler d1 create で DB を作る

Workers プロジェクト(my-api)の中で実行します。Workers をまだ用意していない場合は、先に Workers の手順 を通してください。

cd my-api
npx wrangler d1 create my-db

数秒で出力が返ってきます。

✅ Successfully created DB 'my-db'

[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

下半分の TOML スニペットがそのまま wrangler.toml に追記する設定 です。binding = "DB" がコード側からの参照名になります(env.DB で呼ぶ)。

手順 2:wrangler.toml に bindings を書く

生成されたスニペットを wrangler.toml の末尾にそのまま貼ります。

name = "my-api"
main = "src/index.ts"
compatibility_date = "2026-01-01"

[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

これで Worker 側から env.DB で D1 が見えるようになります。database_id は Git に入っても問題ありません(ID 単体ではアクセスできず、Cloudflare のアカウント認証が必須なため)。

手順 3:スキーマを migrations/0001_init.sql に書く

D1 はマイグレーション機構を内蔵しています。スキーマ変更を SQL ファイルとして版管理する設計です。

npx wrangler d1 migrations create my-db init

migrations/0001_init.sql が空ファイルで作られるので、テーブル定義を書きます。

-- migrations/0001_init.sql
CREATE TABLE IF NOT EXISTS feedback (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL,
  message TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_feedback_created_at ON feedback (created_at DESC);

SQLite なので、PostgreSQL や MySQL から来た人は 型と関数の挙動の違いだけ気をつけます(SERIAL ではなく INTEGER PRIMARY KEY AUTOINCREMENT、日付は TEXT に ISO8601 文字列が無難など)。

手順 4:migrations apply で適用

ローカルと本番、両方に適用できます。

# ローカル(wrangler dev で使う sqlite ファイルに反映)
npx wrangler d1 migrations apply my-db --local

# 本番(Cloudflare の D1 に反映)
npx wrangler d1 migrations apply my-db --remote

--local は手元の .wrangler/state/v3/d1/ 配下に SQLite ファイルを置きます。--remote は本番の D1 にスキーマを反映します。ローカルと本番でデータは別物なので、本番にだけテーブルを作って、ローカルでテストデータを汚しても本番には影響しません(逆もしかり)。

スキーマが入ったか確かめたいときは、対話コンソールが速いです。

npx wrangler d1 execute my-db --remote --command="SELECT name FROM sqlite_master WHERE type='table'"

feedback テーブルが返ってくれば、本番への適用は完了です。

手順 5:Worker から読み書きする

src/index.ts を書き換えます。

interface Env {
  DB: D1Database;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === '/api/feedback' && request.method === 'POST') {
      const { email, message } = await request.json<{ email: string; message: string }>();

      await env.DB
        .prepare('INSERT INTO feedback (email, message) VALUES (?, ?)')
        .bind(email, message)
        .run();

      return Response.json({ ok: true });
    }

    if (url.pathname === '/api/feedback' && request.method === 'GET') {
      const { results } = await env.DB
        .prepare('SELECT id, email, message, created_at FROM feedback ORDER BY created_at DESC LIMIT 50')
        .all();

      return Response.json({ results });
    }

    return new Response('Not Found', { status: 404 });
  },
};

D1 のクエリは prepare → bind → run/first/all の 3 段。

  • prepare(sql):プレースホルダ(?)入りの SQL を準備
  • bind(...args):実引数を ? に差し込み(SQL インジェクション対策
  • run() / first() / all():実行・1 行取得・全行取得

文字列連結で SQL を組み立てない。これだけで、個人開発で踏みやすい穴の 9 割は塞がれます。

ローカルで wrangler dev を立てて curl で叩けば動作確認できます。

npx wrangler dev
curl -X POST http://localhost:8787/api/feedback \
  -H 'Content-Type: application/json' \
  -d '{"email":"a@example.com","message":"hello"}'

curl http://localhost:8787/api/feedback

本番へは npx wrangler deploy で 1 行。bindings は自動で繋がるので、Worker のコード側で何も変えずに env.DB が本番の D1 を指します。

観測:コンソール SQL とログ

本番運用に入ると、「いま何行入っているか」「どこで詰まったか」を見たくなります。D1 は対話コンソールが標準で付いています。

# 本番に対する 1 発クエリ
npx wrangler d1 execute my-db --remote --command="SELECT COUNT(*) FROM feedback"

# ファイル経由
npx wrangler d1 execute my-db --remote --file=./debug.sql

Cloudflare ダッシュ → Workers & Pages → D1 → 該当 DB → Console で、ブラウザから同じことができます。SELECT だけならダッシュからの方が体感が速い。

書き込み・読み込みの量は D1 → 該当 DB → Metrics で日次のロウ数が見えます。無料枠(5M rows/day read・100k rows/day write)に対する余裕を、最初の数週間で 1 度確認しておくと安心です。

挫折ポイント

  • --local--remote を取り違える:ローカル DB に入れたつもりが本番に書く(逆も)。migrations applyexecute対象を毎回確認 する習慣を最初に作ります。
  • prepare? プレースホルダで文字列連結に逃げるprepare(\INSERT … VALUES (’${email}’)`) のように書くと SQL インジェクションの入口になります。bind()` を使う規律を 1 度入れたら崩さない。
  • トランザクションは “batch” で書く:D1 は MySQL/Postgres の BEGIN ... COMMIT ではなく、配列 API で複数文を 1 トランザクションとして送ります(env.DB.batch([stmt1, stmt2]))。途中で 1 つでも失敗すれば全部巻き戻ります。
  • マイグレーションをローカルだけに当てて本番を忘れる:本番だけ古いスキーマで動き続け、no such column エラー。Git にコミットしたあと、必ず --remote でも 1 回適用するチェックリストを作る。
  • 無料枠の write 上限 100k/day:1 日 10 万行の書き込みが個人開発で出ることは稀ですが、UPDATE を 1 行ずつ走らせると意外と早く食い潰します。バッチ更新で 1 文に束ねる。
  • インデックスを張り忘れる:SQLite はテーブルスキャンが軽快な印象がありますが、行数が増えれば普通に遅くなります。WHERE で使うカラムには CREATE INDEX を最初から張る。
  • 本番 DB を消すwrangler d1 delete my-db不可逆。ID で誤爆を防ぐ意味でも、name = "my-db-prod" のように環境名を入れておくと、コマンド入力時に手が止まります。

まとめ:D1 は「SQL を書いて bindings で呼ぶ」だけ

最小手順を振り返ります。

  1. npx wrangler d1 create my-db
  2. 生成された TOML スニペットを wrangler.toml に貼る
  3. migrations/0001_init.sql にスキーマを書く
  4. npx wrangler d1 migrations apply my-db --remote
  5. Worker 側で env.DB.prepare(...).bind(...).run() で読み書き

ここまで通れば、Worker + D1 の最小構成が完成します。API + DB のセットアップが、初回でも 30 分以内に終わる。個人開発の進行を止めるのが「サーバーを立てる」から「やりたい機能を考える」に戻ります。

D1 の次は、たいてい 画像や動画のアップロード先が要ります。これには R2(S3 互換のオブジェクトストレージ) が同居していて、Worker から env.MY_BUCKET.put(key, file) のように 1 行で扱えます(別記事で改めて深掘りします)。

そして、API も DB も動き始めたあと、どの呼び出しが何回起きているかを見たくなります。サイト側の入口は 個人開発者が GA4 で最初に設定する 3 つのイベント、入った後の数字の読み方は GA4 で最初に見る 3 つの数字 でまとめました。フロントの計測 ↔ バックエンドの実行が両側から見える状態を最初に作ると、後で「なぜ動いていないか」を掘る入り口が増えます。

最初の 1 テーブルを、まず wrangler d1 migrations apply するところから。


サーバーは動かせる、データも残せる。けれど 読まれない/登録に至らない ── その構造を WHY → HOW で 1 冊にまとめたのが、下記の本です。個人開発者・インディー Web 作者・動画作者向けに、LP・短文・メールの戦場別の書き方と、Claude を相棒にしたプロンプト連鎖の実演を含めて統合しています。

#個人開発#Cloudflare#リファレンス