Cloudflare D1 × Drizzle ORM マイグレーション運用手順|本番・プレビュー分離とロールバック
結論:D1のマイグレーション運用は「環境分離」と「前方互換な変更」で9割決まる
Cloudflare D1 と Drizzle ORM の組み合わせでハマるポイントは、ほぼ次の3つに集約される。
- 本番とプレビューで別のDBを向いていない(ローカル実行と
--remoteの取り違えを含む) - SQLite の ALTER TABLE 制約を知らずに破壊的マイグレーションを流してしまう
- ロールバック手段を用意していない(D1 には「1つ戻す」コマンドがない前提で設計する必要がある)
先に運用の型を決めておけば、ここは事故らない。この記事では実際の drizzle.config.ts・wrangler.toml・コマンド手順まで含めて、運用手順を組み立てる。
なお D1 の無料枠・上限・コマンド仕様は変更されうるため、数値や CLI オプションは必ず公式ドキュメントの最新情報を確認してほしい。
前提:Drizzle Kit は「SQL生成」、適用は wrangler が担当する
D1 は通常の Postgres/MySQL のようにローカルから直接接続できない。そのため役割分担はこうなる。
| 工程 | 担当 | コマンド |
|---|---|---|
| スキーマ定義 | Drizzle(TypeScript) | schema.ts を書く |
| 差分SQL生成 | Drizzle Kit | drizzle-kit generate |
| SQL適用 | Wrangler | wrangler d1 migrations apply |
| 適用履歴管理 | D1側のテーブル | 自動記録される |
ここを混同して「drizzle-kit migrate が本番に効かない」と悩むケースが多い。D1 では適用は wrangler 側の仕事と覚えておくと迷わない。
Step 1:スキーマとDrizzle設定の実コード
schema.ts
// src/db/schema.ts
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
export const notes = sqliteTable(
'notes',
{
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
title: text('title').notNull(),
body: text('body').notNull().default(''),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
},
(t) => ({
userIdx: index('notes_user_id_idx').on(t.userId),
}),
);
drizzle.config.ts
// drizzle.config.ts
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts',
out: './migrations', // wrangler が読むディレクトリと揃える
dialect: 'sqlite',
driver: 'd1-http', // D1 向けドライバ
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
token: process.env.CLOUDFLARE_D1_TOKEN!,
},
} satisfies Config;
ポイントは out を wrangler.toml の migrations_dir と一致させること。ここがズレると「生成したのに適用されない」が起きる。
Drizzle Kit はバージョンによって設定キー名(driver / dialect 周り)が変わってきた経緯がある。手元のバージョンの公式ドキュメントを確認したうえで書くのが安全だ。
Step 2:本番/プレビュー環境の分離設定
事故の温床がここ。環境ごとに別の D1 データベースを作り、wrangler.toml の environment で明示的に分ける。
# wrangler.toml
name = "my-app"
main = "src/index.ts"
compatibility_date = "2025-01-01"
migrations_dir = "migrations"
# デフォルト(開発者のローカル・プレビュー用)
[[d1_databases]]
binding = "DB"
database_name = "my-app-preview"
database_id = "xxxxxxxx-preview-uuid"
[env.production]
[[env.production.d1_databases]]
binding = "DB"
database_name = "my-app-prod"
database_id = "yyyyyyyy-prod-uuid"
適用コマンドは環境ごとにこうなる。
# ローカル(miniflare のローカルSQLite)
npx wrangler d1 migrations apply my-app-preview --local
# プレビュー(リモート実DB)
npx wrangler d1 migrations apply my-app-preview --remote
# 本番
npx wrangler d1 migrations apply my-app-prod --remote --env production
つまずきポイント3つ
--localと--remoteの取り違え:--localはローカルのファイルにしか効かない。「適用したのに本番が変わらない」の典型原因--env productionの付け忘れ:environment を切っている場合、付け忘れるとデフォルト(プレビュー)側のバインディングが使われる- Pages のプレビューデプロイ:Pages 側は Production/Preview のバインディングを別々に設定できる。ダッシュボードで両方に同じ DB を刺していないか必ず確認する
package.json にスクリプト化して、手打ちの余地を消しておくのが実務的だ。
{
"scripts": {
"db:gen": "drizzle-kit generate",
"db:local": "wrangler d1 migrations apply my-app-preview --local",
"db:preview": "wrangler d1 migrations apply my-app-preview --remote",
"db:prod": "wrangler d1 migrations apply my-app-prod --remote --env production"
}
}
Step 3:日常のマイグレーション手順
schema.tsを編集するnpm run db:genでmigrations/0003_xxx.sqlを生成- 生成されたSQLを必ず目で読む(後述の理由で最重要)
npm run db:localでローカル適用 → テスト実行npm run db:previewでプレビューに適用 → 実環境で動作確認- コードをデプロイ
- 本番バックアップ(エクスポート)を取ってから
npm run db:prod
手順3を飛ばさないこと。Drizzle Kit はカラム名の変更を「削除+追加」と解釈することがあり、対話プロンプトで rename か確認してくる。ここを雑に流すとデータが消える。
Step 4:SQLite / D1 の制約を踏まえた「壊れない変更」
D1 は SQLite ベースなので、ALTER TABLE でできることが限られる。列の型変更や制約変更は、実際には新テーブル作成 → データコピー → 旧テーブル削除 → リネームという手順の SQL が生成される。テーブルが大きいと重く、途中失敗のリスクもある。
安全側に倒すなら、次の順で運用する(Expand and Contract パターン)。
| フェーズ | やること | 特徴 |
|---|---|---|
| Expand | NULL許容の新カラム追加 | 旧コードも動く(前方互換) |
| Migrate | 新旧両方に書き込むコードをデプロイ/既存行をバックフィル | ダウンタイムなし |
| Contract | 旧カラムを参照しないコードをデプロイ後、旧カラム削除 | 十分に間を空ける |
つまり 1回のデプロイで「カラム追加+NOT NULL+旧カラム削除」を同時にやらない。3回に分ければ、どの段階でも切り戻しできる状態を保てる。
注意点として、大量行のバックフィルは1回のクエリでやらず、LIMIT 付きのバッチで分割するほうがよい。D1 にはクエリ時間やレスポンスサイズの制限があり、上限値は変更されうるので公式の最新情報を確認してほしい。
Step 5:ロールバック手順の現実解
正直に書くと、D1 には「直前のマイグレーションだけを自動で戻す」仕組みは用意されていない(少なくとも執筆時点の CLI にはない)。Drizzle Kit も down マイグレーションを標準生成しない。したがって、ロールバックは自前で設計する必要がある。
実務で機能する順に3つ。
A. コードだけ切り戻す(第一選択)
スキーマを前方互換に保っていれば、DBは触らずコードを前のバージョンに戻すだけで復旧できる。Expand and Contract を守る最大の理由がこれ。Workers のデプロイはロールバックが速い。
B. 打ち消しSQLを手で当てる
事前に migrations/down/0003_down.sql のような打ち消しSQLをセットで書いておき、必要時に実行する。
npx wrangler d1 execute my-app-prod --remote --env production \
--file=./migrations/down/0003_down.sql
ただし DROP COLUMN した後にこれをやってもデータは戻らない。構造は戻せてもデータは戻らない点は正しく理解しておく。
C. バックアップから復元(最終手段)
本番適用の直前に必ずエクスポートを取る。
npx wrangler d1 export my-app-prod --remote --env production \
--output=./backup/prod-$(date +%Y%m%d-%H%M).sql
D1 には Time Travel(過去の時点への復元)機能もあるが、保持期間やプランごとの扱いは変わりうる。頼る前に公式ドキュメントで現在の仕様を確認しておくこと。
これに加えて、CI で「本番適用前に必ずエクスポートを取る」ステップを挟んでおくと、人間の判断に依存しなくなる。個人開発でも、この1ステップの有無が事故の被害を大きく分ける。
デメリット・注意点
公平のために、この構成の弱点も挙げておく。
- ロールバックが手動前提:RDBのマイグレーションツールに慣れていると面倒に感じる
- プレビュー環境のデータ差:プレビューDBのデータ量が本番と違うと、重いマイグレーションの所要時間を見誤る
- Drizzle Kit の設定仕様が変化しやすい:バージョンアップ時に設定キーの移行が必要になる場合がある
- SQLite方言の制約:Postgres前提の設計をそのまま持ち込むと、後から型変更で苦しむ
それでも、Workers との統合の手軽さと運用コストの低さは大きい。Cloudflare スタックで作るなら、Cloudflare Queues × Workers TypeScript で作る最小ジョブキューのような非同期処理と組み合わせて、バックフィルをキュー経由で流す設計も相性がよい。
SQLite の内部仕様やインデックス設計まで踏み込むなら、

SQLアンチパターン 第2版 ―データベースプログラミングで陥りがちな失敗とその対策
参考価格¥4,290(税込)
スキーマ設計の失敗パターンを先に知っておくと、D1の制約下でも判断を誤りにくい
Amazonで最新価格をチェックのような一冊を手元に置いておくと判断が速くなる。
まとめ:チェックリスト
本番適用の前に、この6項目を確認する。
- 生成SQLを目視した(意図しない DROP がないか)
-
--envと--remoteが正しい - ローカル → プレビューの順で検証済み
- 前方互換な変更になっている(コードだけ切り戻せる)
- 本番のエクスポートを取得済み
- 打ち消しSQLを用意した
手順をスクリプト化し、環境を物理的に分け、変更を小さく刻む。この3つだけで D1 のマイグレーション運用はかなり安定する。