「GitHub 連携で自動デプロイ、無料枠で動いて、型安全」— 個人開発の API にはこの 3 点がそろった Cloudflare Workers + D1 の組み合わせが最小構成として成立します。本記事では、筆者が複数のプロダクションで運用しているスケルトンから本番デプロイまでの実装パターンを、コピペで動く粒度でまとめます。
なぜ Workers + D1 なのか
個人 API を動かす基盤として Workers + D1 を選ぶ理由は 3 つです。
- コールドスタートが実質 0ms。Isolate ベースなので Lambda のような「最初の 1 リクエストだけ遅い」が発生しにくい
- D1 バインディングで直接 SQLite が叩ける。外部 DB への TCP 接続や connection pool を意識する必要がない
- 無料枠が実用的。Workers は 1 日 10 万リクエスト、D1 は 500 万 row read / 10 万 row write / 日まで無料
「Rust で書き直すほどの体力も、Lambda + RDS を組み上げる気力も無いが、それでも型安全な API を個人で運用したい」という隙間に、Workers + D1 はきれいにはまります。
到達点と前提
作るもの: 投稿 API (POST /posts / GET /posts/:id)。筆者が実際に動かして確認したバージョンは以下です (本記事執筆時点 2026-04 の最新)。
- Wrangler v4.84 / Hono v4.12 /
@hono/zod-validatorv0.7(Zod 3.25+ / Zod 4 両対応) - Drizzle ORM v0.45 / drizzle-kit v0.31 / Zod v4.3
- TypeScript v6 / Node.js 20+ (ローカルツールチェーン用)
- Cloudflare アカウント (無料で可)
本記事のコードは Zod 4 で動作しますが、z.object({ ... }) と z.string().min(1).max(N) 程度であれば Zod 3.25+ でもそのまま通ります。
リクエストの流れは次のとおり。エッジの Worker で Zod 検証を通し、そのまま D1 バインディング経由で SQLite に書き出します。
client ── HTTPS ──▶ Worker (Hono)
├─ zValidator (Zod)
└─ drizzle(env.DB) ── D1 binding ──▶ D1 (SQLite)
プロジェクトセットアップ
Wrangler v4 から wrangler.jsonc 形式が推奨になりました。IDE 補完と JSON Schema が効くので .toml より扱いやすく、D1 バインディングの記述もシンプルです。
// wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "r43lab-posts-api",
"main": "src/index.ts",
"compatibility_date": "2026-04-01",
"d1_databases": [
{
"binding": "DB",
"database_name": "posts",
"database_id": "<created-below>",
"migrations_dir": "migrations"
}
],
"observability": {
"enabled": true
}
}
observability.enabled: true を入れておくと Workers Logs が自動収集され、wrangler tail を繋いでいないときでも Cloudflare Dashboard から過去ログを検索できます。本番では必須級のベストプラクティスです。
D1 スキーマとマイグレーション
スキーマを Drizzle ORM で TypeScript として書き、drizzle-kit で SQL マイグレーションを生成、wrangler d1 migrations で適用します。
// src/schema.ts
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
export const posts = sqliteTable("posts", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
body: text("body").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
});
セットアップ手順は以下です。
npx wrangler d1 create posts
# 返ってきた database_id を wrangler.jsonc に貼る
npx drizzle-kit generate # migrations/ に SQL 生成
npx wrangler d1 migrations apply posts --local # ローカル dev 用
npx wrangler d1 migrations apply posts --remote # 本番
ローカル (wrangler dev) と本番の D1 は完全に別リソースです。ローカルでマイグレーションが通ったからといって、本番適用を忘れると、デプロイ直後の API が no such table: posts を吐いて 500 を返します。この落とし穴は後述。
Hono + Zod で API を組む
Hono は Workers 用の軽量 Web フレームワークで、Zod バリデータとの統合 (@hono/zod-validator) がほぼ公式化しています。型をエンドツーエンドで通すには次の 3 点を押さえます。
// src/index.ts
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { zValidator } from "@hono/zod-validator";
import { drizzle } from "drizzle-orm/d1";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { posts } from "./schema";
type Bindings = { DB: D1Database };
const postInput = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1).max(50_000),
});
const app = new Hono<{ Bindings: Bindings }>();
// エラーは中央集約: 4xx は HTTPException を throw、それ以外は 500 を生成
app.onError((err, c) => {
if (err instanceof HTTPException) return err.getResponse();
console.error(err);
return c.json({ code: "internal", message: "unexpected error" }, 500);
});
app.post("/posts", zValidator("json", postInput), async (c) => {
const db = drizzle(c.env.DB);
const input = c.req.valid("json"); // Zod で検証済、型も推論済
const [row] = await db.insert(posts).values(input).returning();
return c.json(row, 201);
});
app.get("/posts/:id", async (c) => {
const id = Number(c.req.param("id"));
if (!Number.isInteger(id)) {
throw new HTTPException(400, { message: "id must be an integer" });
}
const db = drizzle(c.env.DB);
const row = await db.select().from(posts).where(eq(posts.id, id)).get();
if (!row) throw new HTTPException(404, { message: "post not found" });
return c.json(row);
});
export default app;
要点は 3 つです。
Bindings型を生やす。Hono<{ Bindings: Bindings }>()にしておくとc.env.DBがD1Database型で推論され、誤ったキー参照がコンパイル時に落ちますzValidatorで入力検証を一元化。通過後のc.req.valid("json")は Zod スキーマから推論された型を持ち、ハンドラ内で再バリデーションする必要がなくなりますapp.onErrorでエラーを集約。4xx はHTTPExceptionを throw するだけで、それ以外は中央で 500 を作る
落とし穴 / ハマりポイント
実運用で踏んだポイントを 4 つ共有します。
- local D1 と remote D1 のデータは完全に別物。
wrangler d1 execute posts --localと--remoteは別の SQLite を見ています。デプロイ前の--remoteへのマイグレーション適用を忘れると、本番 API 起動直後に 500 が返ります - 無料プランの CPU time は 10ms。画像処理や暗号計算はすぐ引っかかります。有料化で 30 秒まで延ばせますが、それでも「Lambda と同じ感覚」で書くと足りません
- D1 は 1 トランザクション内で並列書き込み不可。
Promise.allでdb.insert()を並列にかけると散発的に 500 が返ります。シーケンシャルに書くか、db.batch([...])を使ってください process.envは存在しない。Workers ランタイムにprocessオブジェクトはありません。環境変数はwrangler.jsoncのvars/secrets経由でc.env.FOOに届けます
本番運用
デプロイとログ確認はコマンド 3 つでまかなえます。
npx wrangler deploy # 本番反映
npx wrangler tail --format=pretty # リアルタイムログ
npx wrangler d1 execute posts --remote --command "SELECT COUNT(*) FROM posts"
observability.enabled を有効にしていれば、Cloudflare Dashboard の Workers Logs から過去のリクエストも検索できます。wrangler tail を繋ぎ忘れていた時間帯の障害も後追いで追えるので、個人運用ほど価値の大きい機能です。
まとめ
Workers + D1 + Hono + Drizzle + Zod。型が通り、無料枠で立ち上がり、git push で本番に出る。一人で回す API には必要十分の構成になります。次は 認証を Better Auth + D1 に載せ替える 話を書く予定です。