「ローカルでは動いています」の中身 — ヘッドレスCMSの開発環境が本番とズレる4か所
手元で動いたのに本番で崩れるのは、ヘッドレスCMS構成だとローカルと本番で土台が入れ替わるから。実行環境・DB・メディア・キャッシュの差、seedファイルの誤解、開発用バイパスの境界を、自サイトの設定と実装から整理しました。

「ローカルでは動いています」。実装を外に出していると、この一言でやり取りが止まることがあります。嘘ではありません。手元では本当に動いている。ただしヘッドレス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

ローカルで見ているのは何か。同じコードが別の土台に乗ったときの姿です。見た目・組み方・コンポーネントの確認ならこれで足りますが、保存・配信・キャッシュが絡む不具合は、まず出てきません。
スキーマの正はデータベースにあって、seedファイルではない
EmDashは seed/seed.json にコレクション定義(フィールド構成)もコンテンツも書けます。設定ファイルの顔をしているので、ここを直せば反映されると思ってしまう。実装はそうなっていませんでした。
seedが流し込まれるのは、次の2つが同時に成り立つときだけです。
- コレクションの登録が 0件
emdash:setup_completeがまだ立っていない
しかも投入のモードは「衝突したら飛ばす」。中身の入ったデータベースには当たりません。フィールドを1つ足したくてseedファイルを編集しても、既存のDBは何も変わらない。スキーマの本体は管理画面のスキーマビルダーが書き込むDB側にあり、seedファイルは空のDBを立ち上げるときの初期値です。テーブル構造の変更(マイグレーション)のほうは、デプロイの瞬間ではなく最初のリクエストで走ります(Cloudflareへのデプロイでも触れました)。

数字で言うと、うちの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に上げた画像は自動で軽くならない)。

「本番と同じ場所で確かめる経路」を先に決める
ここまでの差は、ローカルを本番へ近づけて埋めるものではありません。近づけるほど手元は重くなり、それでも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 の実装を確認しています)。