Cloudflare D1 で個人開発の最小データベースを作る手順
Cloudflare Workers で fetch ハンドラを 1 つデプロイすると、もう「サーバーが立たない」という詰まりは個人開発の進行を止めなくなります。次に欲しくなるのは、データの居場所です。フォームの保存、ユーザー設定、ちょっとした分析テーブル ── これらは Worker のメモリには残せません(リクエストをまたいで消える)。
このとき登場するのが、Cloudflare D1。Cloudflare のサーバーレス SQL データベースで、エンジンは SQLite。Worker と同じインフラに同居して、env.DB という bindings で 1 行から呼べます。本記事では、D1 に 個人開発の最小テーブルを 1 つ作って、Worker から読み書きするまで を、wrangler の d1 create → migrations apply → execute で通します。
Workers の最小デプロイは姉妹記事 Cloudflare Workers で最小の API を公開する手順 で扱いました。本記事はその続編 ── 「API は動いた、次はデータ」 の地続きで読めます。
D1 とは(KV / R2 との違い)
Cloudflare のデータストアは 3 系統あって、用途で使い分けます。
| D1 | KV | R2 | |
|---|---|---|---|
| データモデル | リレーショナル(SQLite) | Key-Value | オブジェクトストレージ |
| 向く用途 | テーブル・JOIN・トランザクション | 設定・キャッシュ・セッション | 画像・動画・大きいファイル |
| 一貫性 | 強い(リーダー書き込み) | 結果整合(数十秒で世界に伝播) | 強い(書き込み確定で読める) |
| 無料枠(2026 年現在) | 5GB/5M rows/day read/100k rows/day write | 100k read/day・1k write/day・1GB | 10GB・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 applyもexecuteも 対象を毎回確認 する習慣を最初に作ります。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 で呼ぶ」だけ
最小手順を振り返ります。
npx wrangler d1 create my-db- 生成された TOML スニペットを
wrangler.tomlに貼る migrations/0001_init.sqlにスキーマを書くnpx wrangler d1 migrations apply my-db --remote- 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 を相棒にしたプロンプト連鎖の実演を含めて統合しています。