部署到 Cloudflare

本頁內容

本指南將 EmDash 網站部署到 Cloudflare Workers,使用 D1 作為資料庫、R2 作為媒體儲存。可從 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。

若資料庫為空(無 collection)且尚未完成設定精靈,EmDash 還會在首次開機時套用 seed 檔案。Seed 在建置時從 .emdash/seed.json、package.json#emdash.seed 中的路徑或 seed/seed.json(以先找到的為準)讀取,並內嵌到套件中。若都不存在,則使用內建預設 seed。對既有資料庫的後續部署不會改動其內容。

要變更已部署網站的 schema 或內容模型,參見 Evolving a Deployed Site。

將 Worker 放在靠近 D1 的位置

Cloudflare 預設在靠近訪客處執行 Worker。EmDash 伺服器端轉譯請求會多次往返 D1,因此請使用 Targeted Placement 在靠近 D1 主庫處執行 Worker,以加快這些請求。

Wrangler 接受恰好帶有一個選擇器的 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 控制自身快取。

啟用前須知兩點:

  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)
Purge來自 cloudflare: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 元件與豐富文字中的影像都透過 EmDash 在 Cloudflare 配接器下安裝的影像端點轉譯。對於內部路由 /_emdash/api/media/file/… 上的媒體,該端點直接從 R2 繫結讀取來源位元組,無需 HTTP 擷取。這些轉換在 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 計費。來源影像與參數的每個唯一組合每個日曆月計費一次,該月內的重複請求免費。若網站有 500 張來源影像,且每張請求一個縮圖尺寸和一個主圖尺寸,則這兩套參數在該月計為 1,000 次轉換影像。Images Free 方案每月涵蓋 5,000 次唯一轉換。超出該限制後,已快取的轉換仍會提供,但新轉換會回傳 9422 錯誤,影像請求失敗。

Cloudflare Access 認證

Cloudflare Access 可用附加到 Access 應用程式的身分提供者取代通行金鑰認證。受眾值是機密執行時期設定;透過命名其環境變數將其排除在 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,選擇 collection,並執行 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 時,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 使用的每個繫結。在 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、管理登入、媒體上傳以及任何可選繫結。切勿將預覽繫結指向正式資料庫或儲存桶。

驗證部署

部署後,請求一個公開頁面,登入 /_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"

遷移錯誤

若看到 schema 錯誤,追蹤 Worker 記錄(wrangler tail)並重現錯誤以擷取底層訊息——然後帶著該輸出提交 issue。