Cloudflare へのデプロイ

このページ

このガイドは、データベースに D1、メディアに R2 を使って EmDash サイトを Cloudflare Workers にデプロイします。EmDash Cloudflare テンプレートから始めるか、既存の Astro サイトに同じ設定を適用してください。

前提条件

  • Cloudflare アカウント
  • プロジェクトの依存関係がインストール済みであること
  • Wrangler が Cloudflare で認証済みであること(pnpm wrangler login)

バインディングの設定

Cloudflare テンプレートには、完全な Worker エントリポイントと名前付きの D1・R2 バインディングが含まれます。初回デプロイ時、設定した名前のリソースがまだ存在しなければ Wrangler が作成します。wrangler.jsonc の名前を維持してください。Wrangler は以降のデプロイを同じリソースに再接続します。

テンプレートは次のバインディングを使います。

{
	"$schema": "node_modules/wrangler/config-schema.json",
	"name": "my-emdash-site",
	"main": "./src/worker.ts",
	"compatibility_date": "2026-02-24",
	"compatibility_flags": ["nodejs_compat"],

	"d1_databases": [
		{
			"binding": "DB",
			"database_name": "my-emdash-site",
		},
	],

	"r2_buckets": [
		{
			"binding": "MEDIA",
			"bucket_name": "my-emdash-media",
		},
	],
	"worker_loaders": [{ "binding": "LOADER" }],
	"triggers": { "crons": ["* * * * *"] },
}

DB、MEDIA、LOADER の名前は EmDash アダプターと一致している必要があります。Cron Trigger は、スケジュール公開、プラグインタスク、バックアップ、メンテナンスを実行します。サイトでサンドボックス化されたプラグインを使う場合は、Plugin sandbox を参照してください。

EmDash の設定

次の Astro 設定は D1 と R2 のバインディングを使います。

import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import emdash from "emdash/astro";
import { d1, r2, sandbox } from "@emdash-cms/cloudflare";

export default defineConfig({
	output: "server",
	adapter: cloudflare(),
	integrations: [
		react(), // Required — the admin UI is a React app
		emdash({
			database: d1({ binding: "DB" }),
			storage: r2({ binding: "MEDIA" }),
			sandboxRunner: sandbox(),
		}),
	],
});

サイトで marketplace、registry、sandboxed プラグインを使わない場合は、sandboxRunner と LOADER バインディングを省略できます。

Worker エントリポイントの追加

Worker エントリポイントは、Astro を Cron Trigger に接続し、プラグインブリッジをエクスポートします。

import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker";

export { PluginBridge };

export default {
	...handler,
	scheduled: createScheduledHandler(),
} satisfies ExportedHandler;

サンドボックス化されたプラグインがインストールされていなくても、PluginBridge のエクスポートは無害です。同じプロジェクトで後からプラグインを有効にする可能性がある場合は残しておいてください。

一般的なメンテナンスを毎分以外のスケジュールで実行するには、同じ Cron 式を createScheduledHandler({ generalCron: "..." }) と triggers.crons の両方に渡します。両者が異なる場合、ハンドラーは想定外のトリガーをログに記録して無視します。

ビルドとデプロイ

サイトを一度ビルドしてデプロイし、Wrangler に名前付きの D1 データベースと R2 バケットをプロビジョニングさせます。Wrangler は pnpm wrangler login で作成されたローカルのログインを使います。

pnpm build
pnpm wrangler deploy

デフォルトの auto マイグレーションモードでは、デプロイされた Worker が最初のリクエストを受け取ったときに、EmDash が保留中のコアマイグレーションを適用します。新しいコードがトラフィックを受ける前にデプロイパイプラインでマイグレーションを適用する必要がある場合や、マイグレーションを確認・チェック・復旧する必要がある場合は、Manage core database migrations を使ってください。

セットアップウィザードが完了するまでは、EmDash は最初のリクエスト時に、シードファイルのスキーマと構造も一度だけ適用します。シードのサンプルコンテンツは、管理者がセットアップウィザードで選択した場合にのみ適用されます。シードはビルド時に .emdash/seed.json、package.json#emdash.seed のパス、seed/seed.json のうち最初に見つかったものから読み込まれ、バンドルにインライン化されます。いずれも存在しない場合は、組み込みのデフォルトシードが使われます。既存のデータベースに対する以降のデプロイでは、そのコンテンツには手を加えません。

すでにデプロイ済みのサイトのスキーマやコンテンツモデルを変更するには、Evolving a Deployed Site を参照してください。

スケジュール公開

Cloudflare では、EmDash はスケジュールされたエントリを Worker の scheduled ハンドラーからのみ公開します。このハンドラーは、Cron Trigger のスケジュールに従って Cloudflare が呼び出します。デプロイされた Worker には、次の両方が必要です。

どちらかが欠けていると、スケジュールされたエントリは公開されません。astro dev を使ったローカル開発では、EmDash はタイマーでスケジュールされたエントリを公開するため、ローカルで動作するスケジュールがデプロイ済みの設定を裏付けるわけではありません。

プロジェクトのルートから emdash doctor を実行して、ローカルのプロジェクトファイルにある Cron Trigger と scheduled ハンドラーを確認します。

pnpm emdash doctor

emdash doctor が両方を見つけると、出力に合格した scheduler wiring チェックが含まれます。スケジューラーチェックが失敗した場合は、問題の説明と修正方法が表示されます。このチェックは、このページに示した Worker エントリポイントと、修正方法として表示される 1 行の再エクスポートを認識します。そのため、別の方法で書かれたエントリポイントは、動作していてもチェックに失敗することがあります。

スケジューラーチェックが表示されない場合、emdash doctor が現在のディレクトリで Wrangler 設定を見つけられなかったか、Cron Trigger も scheduled ハンドラーも見つけられなかったことを意味します。D1 プロジェクトでは、database チェックはローカルの SQLite ファイル(デフォルトは ./data.db)を探すため失敗します。この失敗はスケジューラーチェックには影響しません。

Cron Trigger や scheduled ハンドラーを追加または変更したら、再デプロイしてください。Cloudflare が新規または変更された Cron Trigger の実行を開始するまでに、最大 15 分かかることがあります。トリガーが実行され始めると、EmDash はスケジュール時刻をすでに過ぎたエントリを公開します。

トリガーを監視するには、pnpm wrangler tail を実行します。実行のたびに "* * * * *" @ 9/29/2026, 10:01:00 AM - Ok のような行が出力されます。Ok は、その実行がキャッチされないエラーなしで終了したことを意味します。EmDash は公開時のエラーをキャッチしてログに記録するため、Ok はエントリが公開されたことを保証しません。エントリを公開した実行では [scheduled] Published 1 scheduled item(s) のようなメッセージもログに記録され、その実行のエラーも同じ出力に表示されます。

デプロイ後、スケジュールされたエントリがスケジュール時刻を過ぎていて、直近 5 分間にスケジューラーの実行が完了していない場合、管理ダッシュボードに Scheduled publishing needs attention が表示されます。Cron Trigger の実行間隔が 5 分より長い場合は、次の実行でそのエントリが公開されるまでの間にも、この通知が表示されることがあります。

Worker を D1 の近くに配置する

Cloudflare はデフォルトで、訪問者の近くで Worker を実行します。EmDash のサーバーレンダリングされたリクエストは D1 との往復を何度も行うため、Targeted Placement を使って Worker を D1 プライマリの近くで実行し、これらのリクエストを高速化してください。

Wrangler は placement.mode: "targeted" を受け付け、セレクターはちょうど 1 つ、region、host、hostname のいずれかを指定します。D1 プライマリの場所を指す値を選び、得られた placement オブジェクトを wrangler.jsonc に追加してください。Targeted Placement と D1 リードレプリカを併用しないでください。EmDash の session 設定はデフォルトの "disabled" のままにして、読み取りと書き込みが近くのプライマリを使うようにします。

オブジェクトキャッシュ

D1 の読み取り負荷を減らすには、コンテンツと設定のクエリ結果を Cloudflare KV にキャッシュします。読み取りは、リクエストのたびにデータベースへクエリする代わりに KV から提供されます。

import { d1, r2, kvCache } from "@emdash-cms/cloudflare";

emdash({
	database: d1({ binding: "DB" }),
	storage: r2({ binding: "MEDIA" }),
	objectCache: kvCache({ binding: "CACHE" }),
}),

KV のセットアップ、オプション、無効化の動作については、Object Cache を参照してください。

Workers Cache

Cloudflare の Workers Cache は、Worker の前段にエッジキャッシュを置きます。一致するリクエストは、Worker をまったく実行せずに提供されます。

有効化

  1. Astro の Cloudflare キャッシュプロバイダーを使い、ルートルールと Astro.cache でキャッシュヘッダーを設定し、無効化には cache.purge() を使います。

    import { cacheCloudflare } from "@astrojs/cloudflare/cache";
    
    export default defineConfig({
     adapter: cloudflare(),
     cache: {
       provider: cacheCloudflare(),
     },
     routeRules: {
       "/": { maxAge: 300, swr: 86400 },
       // Other public routes can use different cache lifetimes.
     },
    });

    @astrojs/cloudflare アダプターは cacheCloudflare() を検出し、生成されるデプロイ設定で Workers Cache を有効にします。

  2. プラットフォーム API を使って、Worker コードからキャッシュされたレスポンスをパージします。この呼び出しに Cloudflare REST の認証情報は不要です。

    import { cache } from "cloudflare:workers";
    
    await cache.purge({ purgeEverything: true });
    // Or purge selected tags:
    await cache.purge({ tags: ["posts"] });

EmDash の管理画面と API のレスポンスはすでに Cache-Control: private, no-store を送信しており、保存されることはありません。公開ページは Cache-Control / routeRules / Astro.cache を通じて、自身のキャッシュを制御します。

有効にする前に知っておくべきことが 2 つあります。

  1. Cache-Control ヘッダーのないレスポンスもキャッシュされます。 Workers Cache は RFC 9111 のヒューリスティックな鮮度を適用します。ヘッダーのない 200 は 2 時間キャッシュされます。すべてのカスタムルートに明示的な Cache-Control を指定してください(セッションに依存するものには private, no-store を使います)。
  2. キャッシュされたページはログイン中の編集者とも共有されます。 キャッシュは Worker より前で動作するため、リクエストの cookie に基づいてバイパスできません。ログイン中の編集者は、エントリが期限切れになるまで、公開ページのキャッシュされた匿名版(ビジュアル編集ツールバーなし)を受け取ることがあります。編集者向けにレンダリングされたレスポンス自体は保存されない(private, no-store が付く)ため、逆方向への漏えいはありません。

Workers Caching はオブジェクトキャッシュ(objectCache: kvCache({ binding: "CACHE" }))とは別のもので、後者は Worker の下の KV にデータベースクエリの結果をキャッシュします。

カスタムドメイン

初回デプロイでは workers.dev の URL が割り当てられます。カスタムドメインは、Worker と同じアカウントで Cloudflare が管理する有効なドメインである必要があります。Worker が workers.dev の URL で正常に応答したら、本番ドメインを Wrangler のルートとして追加します。

{
	"routes": [{ "pattern": "www.example.com", "custom_domain": true }],
}

再度デプロイし、両方のアドレスを確認します。DNS をテストしている間は workers.dev のアドレスを使える状態にしておくと、ルーティングの問題とアプリケーションの問題を切り分けやすくなります。

siteUrl、EMDASH_SITE_URL、SITE_URL のいずれもない場合、セットアップは実行されているアドレスをサイトのオリジンとして記録し、セットアップ中に作成されたパスキーはそのアドレスでしか動作しません。本番ドメイン上でセットアップを実行するか、セットアップの前に EMDASH_SITE_URL をそのドメインに設定してください。セットアップ後に EMDASH_SITE_URL を別のドメインに設定すると、サインインはそのドメインに移り、以前のアドレスで作成されたパスキーはそのドメインでは動作しなくなります。

公開 R2 アクセス

デフォルトでは、メディアは EmDash の認証済みメディアルート経由で提供されます。バケットに公開カスタムドメインがある場合は、そのオリジンを publicUrl に設定して、生成されるメディア URL がそれを使うようにします。

storage: r2({
	binding: "MEDIA",
	publicUrl: "https://media.example.com",
}),

バケットの公開アクセスは、メディアだけでなく、到達可能なすべてのオブジェクトに適用されます。自動 JSON バックアップは同じストレージバックエンドの backups/ プレフィックスを使うため、そのプレフィックスを公開ドメイン経由で公開しないでください。Choose media storage で、安全な境界を説明しています。

画像変換

EmDash は、Cloudflare の IMAGES バインディングを通じて、Worker 内で R2 メディアのリサイズと再エンコードを行います。emdash/ui の Image コンポーネントとリッチテキスト内の画像は、どちらも Cloudflare アダプターの下で EmDash がインストールする画像エンドポイントを通じてレンダリングされます。内部の /_emdash/api/media/file/… ルート上のメディアでは、そのエンドポイントが HTTP フェッチなしで R2 バインディングから直接ソースのバイト列を読み取ります。これらの変換は、Cloudflare Access の背後でも、global_fetch_strictly_public を使っていても引き続き動作します。バケット URL から提供されるメディア(公開 R2 アクセスを参照)では、代わりにアダプター自身の変換エンドポイントが使われ、変換前に HTTP 経由でファイルをフェッチします。

バインディングを宣言する必要はありません。@astrojs/cloudflare が astro build の間に、生成する Worker 設定へ追加します。これは Workers Caching 用に cache を追加するのと同じ方法です。ランタイムの画像サービスが cloudflare-binding の場合に追加されます。つまり、imageService が未設定、その文字列そのもの、または { runtime: "cloudflare-binding" } の場合です。それ以外の値("passthrough"、"compile"、"cloudflare"、"custom")では、バインディングは追加されません。自分の wrangler.jsonc に記載しておくと、意図が明確になります。

{
	"images": {
		"binding": "IMAGES",
	},
}

デプロイが実際に何を取得するかを確認するには、wrangler.jsonc ではなく生成された設定を読んでください。ビルドは .wrangler/deploy/config.json を書き出し、これが wrangler deploy をマージ済みファイル(デフォルトでは dist/server/wrangler.json)に向けます。そこで images エントリを探してください。

Cloudflare は、これらの変換を Images transformations として課金します。ソース画像とパラメーターの一意の組み合わせごとに、暦月につき 1 回課金され、その月内の繰り返しリクエストは無料です。サイトにソース画像が 500 枚あり、すべての画像についてサムネイルサイズとヒーローサイズを 1 つずつリクエストする場合、その 2 つのパラメーターセットで、その月は 1,000 枚の変換済み画像として数えられます。Images Free プランでは、月あたり 5,000 件の一意の変換が対象です。この上限を超えると、キャッシュ済みの変換は引き続き提供されますが、新しい変換は 9422 エラーを返し、画像リクエストが失敗します。

Cloudflare Access 認証

Cloudflare Access は、Access アプリケーションに紐づく ID プロバイダーで、パスキー認証を置き換えられます。オーディエンス値はシークレットのランタイム設定です。環境変数名を指定することで、astro.config.mjs に含めないようにします。

import { access } from "@emdash-cms/cloudflare";

emdash({
	auth: access({
		teamDomain: "myteam.cloudflareaccess.com",
		audienceEnvVar: "CF_ACCESS_AUDIENCE",
		roleMapping: {
			Admins: 50,
			Editors: 40,
		},
	}),
}),

CF_ACCESS_AUDIENCE は pnpm wrangler secret put CF_ACCESS_AUDIENCE で設定します。authentication guide では、ユーザーのプロビジョニング、デフォルトロール、ロール同期を説明しています。

メール

本番の Worker には、デフォルトのメール配信サービスがありません。メールプラグインが有効になるまで、マジックリンクによるサインイン、チームへの招待、コメント通知は Email is not configured を返します。

Cloudflare メールプラグインは send_email バインディングを使います。まず、Cloudflare Email Sending で送信元ドメインをオンボードして検証してください。Cloudflare は、From アドレスが承認された送信元でないメッセージを拒否します。

バインディングを追加し、プロバイダーを登録します。

{
	"send_email": [{ "name": "EMAIL" }],
}
import { cloudflareEmail } from "@emdash-cms/cloudflare/plugins";

emdash({
	plugins: [
		cloudflareEmail({
			from: { email: "[email protected]", name: "My Site CMS" },
			replyTo: "[email protected]",
		}),
	],
}),

デプロイ後、Extensions でプラグインを有効にし、Settings → Email で選択します。送信元が承認され、バインディングが存在するまで、送信は失敗します。

プラグインは、binding オプションで別の名前を指定しない限り、EMAIL という名前のバインディングを使います。有効なメールプロバイダーがこれ 1 つだけの場合、EmDash は自動的にそれを選択します。複数のプロバイダーが有効な場合は、Settings → Email で Cloudflare プロバイダーを選択してください。省略可能な replyTo アドレスは、承認済みの From アドレスを変更せずに返信を受け取ります。プラグインは個々のメッセージに replyTo を設定でき、そのメッセージについてはこのオプションを上書きします。

AI Search プラグインには、ネイティブプラグインの登録と ai_search_namespaces バインディングの両方が必要です。これらをデプロイしたら、管理画面で Cloudflare AI Search を開き、コレクションを選択して、Sync All Content を実行します。初回の同期では、プラグインを有効にする前に公開されたコンテンツがインデックスされます。以降の変更は hooks によって同期され続けます。

import { aiSearch } from "@emdash-cms/cloudflare/plugins";

emdash({
	plugins: [aiSearch()],
}),
{
	"ai_search_namespaces": [{ "binding": "AI_SEARCH", "namespace": "default" }],
}

サイトから検索ルートを公開します。

export { POST, prerender } from "@emdash-cms/cloudflare/plugins/ai-search";

検索インターフェースをレイアウトに追加します。トリガースロットには、サイトのデザインに合うボタンを渡せます。

---
import AISearchSnippet from "@emdash-cms/cloudflare/plugins/ai-search/astro";
---

<AISearchSnippet apiUrl="/api/ai-search" placeholder="Search content">
	<button slot="trigger" type="button">Search</button>
</AISearchSnippet>

Worker シークレット

シークレットの値は pnpm wrangler secret put <NAME> で保存します。wrangler.jsonc に入れたり、ビルド時の import.meta.env の値から読み取ったりしないでください。

EMDASH_ENCRYPTION_KEY は、シークレットとして宣言されたプラグイン設定を暗号化します。プラグインのシークレットを保存する前に設定し、D1 バックアップとは別に保管してください。ローテーション中は、新しいキーを先に指定し、すべてのプラグインシークレットが再保存されるまで、古いキーをカンマの後に残します。ローテーションと復旧については、Secrets and key management を参照してください。

プラグインブリッジは、この Worker シークレットのバインディングを直接読み取ります。生成される管理設定ルートは、process.env 経由で読み取ります。nodejs_compat を使うと、互換性日付が 2025-04-01 以降の場合、Cloudflare はデフォルトで process.env を設定します。それより前の日付に固定しているプロジェクトでは、暗号化された設定を保存する前に nodejs_compat_populate_process_env も追加する必要があります。

EmDash は、実行時に process.env からシークレットを読み取ります。Worker コードは、cloudflare:workers からインポートした env からバインディングを読み取ります。シークレットを import.meta.env 経由で読み取らないでください。Vite はこれらの値をビルド時に置き換えるため、サーバーバンドルに書き込まれる可能性があります。

プレビュー HMAC シークレットとコメント投稿者 IP のソルトは、ランタイムでの上書きを指定しない限り、生成されてデータベースに保存されます。正確な変数、保存場所、ローテーションの影響は、Secrets and key management に一覧があります。

プレビューデプロイ

名前付きの Wrangler 環境はバインディングを継承しません。プレビュー専用のリソースを別途作成し、ビルド前に preview 環境へ書き込みます。

pnpm wrangler d1 create my-emdash-site-preview \
  --binding DB --env preview --update-config
pnpm wrangler r2 bucket create my-emdash-media-preview \
  --binding MEDIA --env preview --update-config

プレビュー環境では、プレビュー Worker が使うすべてのバインディングを繰り返し記述する必要があります。Wrangler がリソース識別子を書き込んだ後の、コアの D1、R2、サンドボックスのバインディングは次の形になります。

{
	"env": {
		"preview": {
			"d1_databases": [
				{
					"binding": "DB",
					"database_name": "my-emdash-site-preview",
					"database_id": "00000000-0000-0000-0000-000000000000",
				},
			],
			"r2_buckets": [
				{
					"binding": "MEDIA",
					"bucket_name": "my-emdash-media-preview",
				},
			],
			"worker_loaders": [{ "binding": "LOADER" }],
		},
	},
}

Wrangler が書き込んだプレビューの UUID を使ってください。プレビューでこれらの機能を使う場合は、省略可能な KV、AI Search、メール、その他のバインディングも繰り返し記述します。プレビュー専用のシークレットは pnpm wrangler secret put <NAME> --env preview で追加します。

プレビュー環境をビルドしてデプロイします。最初のリクエストで、デフォルトの auto モードにより保留中のコアマイグレーションが適用されます。

pnpm build
pnpm wrangler deploy --env preview

共有する前に、プレビュー URL、管理画面へのサインイン、メディアのアップロード、および省略可能なバインディングを確認してください。プレビューのバインディングを本番のデータベースやバケットに向けてはいけません。

デプロイの確認

デプロイ後、公開ページを 1 つリクエストし、/_emdash/admin にサインインして、テスト用のメディアファイルをアップロードして取得し、pnpm wrangler tail に scheduled ハンドラーが表示されることを確認します。

トラブルシューティング

「D1 binding not found」

wrangler.jsonc のバインディング名がデータベース設定と一致していることを確認します。

// Must match: d1({ binding: "DB" })
"binding": "DB"

「R2 binding not found」

R2 バケットが正しくバインドされていることを確認します。

// Must match: r2({ binding: "MEDIA" })
"binding": "MEDIA"

マイグレーションエラー

スキーマエラーが表示される場合は、Worker のログを tail(wrangler tail)し、エラーを再現して根本のメッセージを取得してください。その出力を添えて issue を報告します。