「ローカルでは動いています」。実装を外に出していると、この一言でやり取りが止まることがあります。嘘ではありません。手元では本当に動いている。ただしヘッドレスCMSの構成だと、ローカルと本番で土台そのものが入れ替わっていることがある。

自分のサイト(tsudzuri.com)もそうで、手元はNodeとSQLiteの1ファイル、本番はCloudflare Workers+D1+R2。同じなのはソースコードだけ。どこが入れ替わると、何がローカルでだけ動くのか。EmDash 0.32.0 の実装と自分のリポジトリの設定を読みながら、4か所に絞って整理します。

入れ替わるのは「土台」の4つ

うちの astro.config.mjs は、環境変数ひとつで構成を丸ごと差し替えています。

if (isCloudflare) {
	adapter = cloudflare();
	database = d1({ binding: "DB", session: "auto" });
	storage = r2({ binding: "MEDIA" });
	objectCache = kvCache({ binding: "CACHE" });
} else {
	adapter = node({ mode: "standalone" });
	database = sqlite({ url: "file:./data.db" });
	storage = local({ directory: "./uploads" });
	objectCache = memoryCache();
}
  • 実行環境:Node(standalone)とCloudflare Workers
  • データベース:ローカルのSQLiteファイルとD1
  • メディアの保存先:./uploads ディレクトリとR2バケット
  • オブジェクトキャッシュ:プロセス内のメモリとKV
ローカルと本番で入れ替わる土台の比較表。実行環境、データベース、メディアの保存先、キャッシュの4項目

ローカルで見ているのは何か。同じコードが別の土台に乗ったときの姿です。見た目・組み方・コンポーネントの確認ならこれで足りますが、保存・配信・キャッシュが絡む不具合は、まず出てきません。

スキーマの正はデータベースにあって、seedファイルではない

EmDashは seed/seed.json にコレクション定義(フィールド構成)もコンテンツも書けます。設定ファイルの顔をしているので、ここを直せば反映されると思ってしまう。実装はそうなっていませんでした。

seedが流し込まれるのは、次の2つが同時に成り立つときだけです。

  • コレクションの登録が 0件
  • emdash:setup_complete がまだ立っていない

しかも投入のモードは「衝突したら飛ばす」。中身の入ったデータベースには当たりません。フィールドを1つ足したくてseedファイルを編集しても、既存のDBは何も変わらない。スキーマの本体は管理画面のスキーマビルダーが書き込むDB側にあり、seedファイルは空のDBを立ち上げるときの初期値です。テーブル構造の変更(マイグレーション)のほうは、デプロイの瞬間ではなく最初のリクエストで走ります(Cloudflareへのデプロイでも触れました)。

seedファイルが流し込まれる条件を4ステップで示した図。最初のリクエスト、コレクション0件、setup_complete未設定、衝突は飛ばして投入

数字で言うと、うちのseedファイルに入っているブログ記事は13本で、6月末の状態のまま。本番はいま61本あります。手元の data.db も8月上旬で止まったきりで、ローカルのDBを作り直すと、サイトは6月に戻る。ローカルは本番のコピーではなく別の世界だと割り切ったほうが、事故りません。

ローカルにしか無い近道と、本番にしか無いもの

開発中の管理画面がログインを求めてこないのを、不思議に思ったことはありませんか。EmDashには開発用のバイパス経路があり、CLIはlocalhost相手でトークンが無いときにここを自動で叩きます。中身を読むと、dev@emdash.local という利用者をロール50(Admin)で作り、そのままセッションを張っていました。

境界は明確で、処理の1行目が import.meta.env.DEV を見ています。開発モード以外は403。ビルドした本番には効きません。裏を返せば、開発サーバーをそのまま社外へ公開すると、URLを知っている人がAdminになれる。途中経過を見せるなら、トンネルで開発サーバーを晒すよりスクリーン共有です。

逆向きの差は画像に出ます。ローカルの ./uploads にある画像はローカルにしかなく、本番のR2にある画像は手元にありません。うちの手元は22枚・8.2MB。本番とはまったく別の中身。「ローカルでは画像が出ていたのに」の正体は、たいていこれです。

時計と画像変換も同じ種類のズレを持っています。予約公開はローカルだとプロセス内のタイマーが回してくれるのに、本番のWorkersではCron Triggerを自分で用意しないと動かない。画像の自動変換もCloudflare側のバインディングが要る。どちらも別記事にまとめました(予約投稿が公開されない/CMSに上げた画像は自動で軽くならない)。

ローカルと本番のどちらか片方にしか無い4つ。開発用バイパス、画像の置き場、予約公開の時計、プレビューURL

「本番と同じ場所で確かめる経路」を先に決める

ここまでの差は、ローカルを本番へ近づけて埋めるものではありません。近づけるほど手元は重くなり、それでもD1のレプリカやKVの伝播までは再現できない。現実的なのは、本番と同じ場所で確かめる経路を最初に1本決めておくことです。

Cloudflareなら、本番以外のブランチをビルドするとプレビューURLが出ます。ただしDurable Objectを持つWorkerにはプレビューURLが生成されません(Cloudflare公式ドキュメントに明記)。うちはまさにこれ。予約公開を動かすため、Workerのエントリから PluginBridge(Durable Object)を再エクスポートしています。プレビューURL前提で段取りを組むと、ここで足をすくわれます。

なので、確認の経路は着手前に割り振ります。

  • ローカル:見た目・組み方・コンポーネント。DBの中身は当てにしない
  • ステージング用の別Worker(別のD1・R2):保存、配信、キャッシュ、予約公開まで含めた確認
  • 本番:公開直前の読み取り確認だけ

「ローカルでは動いています」だけでは、報告として足りません。どの土台の、どのデータで動いたのか。そこまで揃って初めて、直ったかどうかを判定できます。

ヘッドレスCMS構成の案件で、確認環境の用意から相談したい方はお問い合わせからどうぞ。いまの構成のどこがローカルで再現できていないのか、洗い出すところからお手伝いします。

本記事は2026年9月時点の情報です(EmDash 0.32.0 の実装を確認しています)。