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 に渡します。異なる場合、ハンドラは予期しないトリガーをログして無視します。

ビルドとデプロイ

名前付き D1 データベースと R2 バケットを Wrangler にプロビジョニングさせるため、サイトを一度ビルドしてデプロイします。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 を参照してください。

Worker を D1 の近くに配置する

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

Wrangler はセレクタをちょうど 1 つ持つ placement.mode: "targeted" を受け付けます。region、host、または hostname です。D1 プライマリの場所を対象とする値を選び、結果の placement オブジェクトを wrangler.jsonc に追加します。Targeted Placement と一緒に D1 リードレプリカを有効にしないでください。EmDash の session 設定はデフォルトの "disabled" のままにし、読み書きが近くのプライマリを使うようにしてください。

Object cache

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 を持つ)ため、逆方向への漏洩はありません。

@emdash-cms/cloudflare の cloudflareCache() と同じではない

推奨: Workers Cachingレガシー: cloudflareCache()
Config"cache": { "enabled": true } + @astrojs/cloudflare/cache の cacheCloudflare()@emdash-cms/cloudflare の cache: { provider: cloudflareCache() }
StoragePlatform Workers CachingCache API(caches.open / put / match)
Purgecloudflare:workers の cache.purge()Zone REST POST /zones/{id}/purge_cache
Secretsパージ用なしCF_ZONE_ID + CF_CACHE_PURGE_TOKEN

新しいサイトでは推奨パスを使ってください。Cache API の動作にすでに依存している場合に限り cloudflareCache() を残してください。

また、どちらも object cache(objectCache: kvCache({ binding: "CACHE" }))と混同しないでください。これはデータベースクエリ結果を KV にキャッシュする、Worker の下の別レイヤーです。

カスタムドメイン

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

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

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

公開 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 から提供されるメディア — Public R2 Access を参照 — は、変換前に 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 プロバイダでパスキー認証を置き換えられます。audience 値はシークレットなランタイム設定です。環境変数名を指定して 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,
		},
	}),
}),

pnpm wrangler secret put CF_ACCESS_AUDIENCE で 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: "cms@mails.example.com", name: "My Site CMS" },
			replyTo: "hello@example.com",
		}),
	],
}),

デプロイ後、Extensions でプラグインを有効化し、Settings → Email で選択します。送信者が受け入れられバインディングが存在するまで送信は失敗します。

プラグインは、binding オプションが別のものを指定しない限り、名前 EMAIL のバインディングを使います。アクティブなメールプロバイダがそれだけの場合、EmDash は自動で選択します。複数のプロバイダがアクティブな場合は、Settings → Email で Cloudflare プロバイダを選んでください。任意の replyTo アドレスは、受け入れ済み From を変えずに返信を受け取ります。プラグインは個々のメッセージに replyTo を設定でき、そのメッセージではこのオプションを上書きします。

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

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";

レイアウトに検索 UI を追加します。トリガースロットはサイトのデザインに合うボタンを受け付けます。

---
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 では、Cloudflare は互換日付 2025-04-01 以降でデフォルトで 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 が使うすべてのバインディングを繰り返す必要があります。コアの D1、R2、サンドボックスバインディングは、Wrangler がリソース識別子を書いた後に次の形になります。

{
	"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 に現れることを確認します。

トラブルシューティング

「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 ログを追跡(wrangler tail)し、エラーを再現して基になるメッセージを取得してから、その出力付きで issue を提出してください。