Deploy to Cloudflare
Cloudflare Workers provides a fast, globally distributed runtime for EmDash. This guide covers deploying with D1 for the database and R2 for media storage.
Prerequisites
Section titled “Prerequisites”- A Cloudflare account
- Wrangler CLI installed (
npm install -g wrangler) - Authenticated with Cloudflare (
wrangler login)
Configure Bindings
Section titled “Configure Bindings”Provision the production D1 database and R2 bucket, then create wrangler.jsonc in your project root with bindings for their immutable IDs and names. Database provisioning is separate from applying EmDash’s schema migrations.
{ "$schema": "node_modules/wrangler/config-schema.json", "name": "my-emdash-site", "compatibility_date": "2025-01-15", "compatibility_flags": ["nodejs_compat"],
"d1_databases": [ { "binding": "DB", "database_name": "emdash-db", "database_id": "00000000-0000-0000-0000-000000000000", }, ],
"r2_buckets": [ { "binding": "MEDIA", "bucket_name": "emdash-media", }, ],}These are the bindings you configure yourself. The @astrojs/cloudflare adapter adds more of its own when it generates the deployed Worker config. One of them is the IMAGES binding that media transforms use — see Image Transformation.
Configure EmDash
Section titled “Configure EmDash”The following Astro configuration uses the D1 and R2 bindings.
import { defineConfig } from "astro/config";import cloudflare from "@astrojs/cloudflare";import react from "@astrojs/react";import emdash from "emdash/astro";import { d1, r2 } 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" }), }), ],});Migrate and Deploy
Section titled “Migrate and Deploy”Runtime migrations remain automatic by default. For deployment-managed migrations, build the Worker and inspect the provisioned D1 target using its account and database UUID.
pnpm buildpnpm exec emdash migrate --status --json \ --account-id "$CLOUDFLARE_ACCOUNT_ID" \ --d1 "$D1_DATABASE_ID"After reviewing and recording the reported target fingerprint, apply the migrations and deploy the same build.
pnpm exec emdash migrate \ --account-id "$CLOUDFLARE_ACCOUNT_ID" \ --d1 "$D1_DATABASE_ID" \ --expected-target-fingerprint "$EMDASH_TARGET_FINGERPRINT"pnpm exec wrangler deployThe migration job requires CLOUDFLARE_API_TOKEN with D1 Edit permission. Serialize jobs by account and database UUID. See Manage Core Database Migrations for provisioning, CI concurrency, runtime modes, and recovery guidance.
If the database is empty (no collections) and the setup wizard hasn’t been completed, EmDash also applies a seed file on first boot. The seed is read at build time from .emdash/seed.json, the path in package.json#emdash.seed, or seed/seed.json — whichever is found first — and inlined into the bundle. If none is present, a built-in default seed is used. Subsequent deploys against an existing database leave its content alone.
To change the schema or content model of a site that is already deployed, see Evolving a Deployed Site.
Scheduled Publishing
Section titled “Scheduled Publishing”On Cloudflare Workers, scheduled publishing, plugin cron, and maintenance tasks run from Worker Cron Triggers. New Cloudflare templates include both required schedules automatically. When updating an existing project, configure distinct general and Media Usage lanes:
import handler, { createScheduledHandler, PluginBridge,} from "@emdash-cms/cloudflare/worker";
export { PluginBridge };
export default { ...handler, scheduled: createScheduledHandler(),} satisfies ExportedHandler;By default, */2 * * * * runs Media Usage maintenance and every other expression runs general maintenance. Then add both Cron Triggers to wrangler.jsonc:
{ "triggers": { "crons": ["* * * * *", "*/2 * * * *"], },}To use different schedules, set the corresponding generalCron or mediaUsageCron option in createScheduledHandler() and use the same expression in wrangler.jsonc.
Deploy
Section titled “Deploy”Deploy to Cloudflare Workers:
wrangler deployYour site is now live at https://my-emdash-site.<your-subdomain>.workers.dev.
Read Replicas
Section titled “Read Replicas”For globally distributed sites, enable D1 read replication to route read queries to nearby replicas instead of always hitting the primary database. This significantly reduces latency for visitors far from the primary region.
emdash({ database: d1({ binding: "DB", session: "auto", }), storage: r2({ binding: "MEDIA" }),}),You also need to enable read replication on the D1 database itself in the Cloudflare dashboard or via the REST API.
See Database Options — Read Replicas for session modes and how bookmark-based consistency works.
Object Cache
Section titled “Object Cache”To reduce read load on D1, cache content and configuration query results in Cloudflare KV. Reads are served from KV instead of querying the database on every request:
import { d1, r2, kvCache } from "@emdash-cms/cloudflare";
emdash({ database: d1({ binding: "DB" }), storage: r2({ binding: "MEDIA" }), objectCache: kvCache({ binding: "CACHE" }),}),See Object Cache for KV setup, options, and invalidation behavior.
Workers Cache
Section titled “Workers Cache”Cloudflare’s Workers Cache puts an edge cache in front of your Worker: matching requests are served without running your Worker at all.
Enable it
Section titled “Enable it”- Turn on the platform cache in
wrangler.jsonc:
{ "cache": { "enabled": true, },}- Use Astro’s Cloudflare cache provider so route rules /
Astro.cacheset the right headers and invalidation uses nativecache.purge():
import { cacheCloudflare } from "@astrojs/cloudflare/cache";
export default defineConfig({ adapter: cloudflare(), cache: { provider: cacheCloudflare(), }, routeRules: { "/": { maxAge: 300, swr: 86400 }, // … },});With cacheCloudflare(), the @astrojs/cloudflare adapter also injects "cache": { "enabled": true } into the generated Wrangler config when it is missing — listing it explicitly in your own wrangler.jsonc keeps the intent obvious.
- Purge from the Worker with the platform API (no Cloudflare REST credentials):
import { cache } from "cloudflare:workers";
await cache.purge({ purgeEverything: true });// or: await cache.purge({ tags: ["posts"] });EmDash admin and API responses already send Cache-Control: private, no-store and are never stored. Public pages control their own caching through Cache-Control / routeRules / Astro.cache.
Two things to know before enabling it:
- Responses without a
Cache-Controlheader are still cached. Workers Cache applies RFC 9111 heuristic freshness — a200without any header is cached for 2 hours. Give every custom route an explicitCache-Control(useprivate, no-storefor anything session-dependent). - Cached pages are shared with logged-in editors. The cache runs before your Worker, so it cannot bypass based on request cookies. A logged-in editor may receive the cached anonymous variant of a public page — without the visual editing toolbar — until the entry expires. Editor-rendered responses themselves are never stored (they carry
private, no-store), so nothing leaks in the other direction.
Not the same as cloudflareCache() from @emdash-cms/cloudflare
Section titled “Not the same as cloudflareCache() from @emdash-cms/cloudflare”| Preferred: Workers Caching | Legacy: cloudflareCache() | |
|---|---|---|
| Config | "cache": { "enabled": true } + cacheCloudflare() from @astrojs/cloudflare/cache | cache: { provider: cloudflareCache() } from @emdash-cms/cloudflare |
| Storage | Platform Workers Caching | Cache API (caches.open / put / match) |
| Purge | cache.purge() from cloudflare:workers | Zone REST POST /zones/{id}/purge_cache |
| Secrets | None for purge | CF_ZONE_ID + CF_CACHE_PURGE_TOKEN |
Use the preferred path for new sites. Keep cloudflareCache() only if you already depend on its Cache API behavior.
Also do not confuse either of those with object cache (objectCache: kvCache({ binding: "CACHE" })), which caches database query results in KV — a separate layer under the Worker.
Custom Domain
Section titled “Custom Domain”Add a custom domain in the Cloudflare dashboard:
- Go to Workers & Pages > your worker
- Click Custom Domains > Add Custom Domain
- Enter your domain and follow the DNS setup instructions
Public R2 Access
Section titled “Public R2 Access”To serve media directly from R2 (recommended for performance):
- In the Cloudflare dashboard, go to R2 > your bucket
- Click Settings > Public access
- Enable public access and note the public URL
- Update your storage config:
storage: r2({ binding: "MEDIA", publicUrl: "https://pub-xxx.r2.dev"}),Image Transformation
Section titled “Image Transformation”EmDash resizes and re-encodes R2 media inside the Worker, through Cloudflare’s IMAGES binding. The Image component from emdash/ui and images in rich text both render through the image endpoint EmDash installs under the Cloudflare adapter. For media on the internal /_emdash/api/media/file/… route, that endpoint reads the source bytes straight from the R2 binding, without an HTTP fetch. Those transforms keep working behind Cloudflare Access and with global_fetch_strictly_public. Media served from a bucket URL — see Public R2 Access — takes the adapter’s own transform endpoint instead, which fetches the file over HTTP before transforming it.
You do not have to declare the binding. @astrojs/cloudflare adds it to the Worker config it generates during astro build, the same way it adds cache for Workers Caching. It does so whenever the runtime image service is cloudflare-binding: imageService unset, the string itself, or { runtime: "cloudflare-binding" }. Every other value — "passthrough", "compile", "cloudflare", "custom" — leaves the binding out. Listing it in your own wrangler.jsonc keeps the intent obvious:
{ "images": { "binding": "IMAGES", },}To see what a deploy actually gets, read the generated config rather than wrangler.jsonc. A build writes .wrangler/deploy/config.json, which points wrangler deploy at the merged file (dist/server/wrangler.json by default). Look for an images entry there.
Cloudflare bills these transforms as Images transformations. Each unique combination of source image and parameters is billed once per calendar month, and repeat requests within that month are free. The Images Free plan covers 5,000 unique transformations per month. Past that limit, cached transformations are still served, but new ones return a 9422 error and the image request fails.
Cloudflare Access Authentication
Section titled “Cloudflare Access Authentication”If your organization uses Cloudflare Access, you can use it as the authentication provider instead of passkeys, giving single sign-on through your existing identity provider. The following configuration enables it:
emdash({ database: d1({ binding: "DB" }), storage: r2({ binding: "MEDIA" }), auth: access({ teamDomain: "myteam.cloudflareaccess.com", audience: "your-app-audience-tag", roleMapping: { "Admins": 50, "Editors": 40, }, }),}),See the Authentication guide for full configuration options.
Cloudflare AI Search
Section titled “Cloudflare AI Search”The AI Search plugin indexes published EmDash content and adds smart search interface to your site.
-
Register the plugin in the
pluginsarray passed to EmDash:astro.config.mjs import { aiSearch } from "@emdash-cms/cloudflare/plugins";// ...plugins: [formsPlugin(),aiSearch(),], -
Add the AI Search namespace binding to your Worker configuration:
wrangler.jsonc {"ai_search_namespaces": [{"binding": "AI_SEARCH","namespace": "default",},],} -
Create the search endpoint used by the search interface:
src/pages/api/ai-search/search.ts export { POST, prerender } from "@emdash-cms/cloudflare/plugins/ai-search"; -
Add the search interface to your site layout. The trigger slot can contain any button that fits your site’s design:
src/layouts/Base.astro ---import AISearchSnippet from "@emdash-cms/cloudflare/plugins/ai-search/astro";---<AISearchSnippet apiUrl="/api/ai-search" placeholder="Search..."><button slot="trigger" type="button">Search</button></AISearchSnippet> -
Deploy the site:
Terminal window pnpm exec wrangler deploy -
Open Cloudflare AI Search in the EmDash admin panel, select the collections to index, and click Sync All Content.
This initial sync is required: the plugin’s content hooks only fire for content created or updated after it was enabled, so anything published beforehand stays missing from the index until you run a full sync.
Content published or updated after setup is kept in sync automatically. The same page shows indexing progress.
On Workers, the only built-in email:deliver handler is a dev console stub, so
email-dependent flows — magic-link login, team invites, and comment
notifications — fail with “Email is not configured” in production. The
cloudflareEmail() plugin delivers real email through
Cloudflare Email Sending
using a native send_email Worker binding, with no external API keys.
1. Onboard a sender domain
Section titled “1. Onboard a sender domain”In the Cloudflare dashboard, go to Email and verify the domain (or address) you send from. Email Sending rejects messages from unverified senders.
2. Add the binding
Section titled “2. Add the binding”Declare a send_email binding in wrangler.jsonc:
{ "send_email": [{ "name": "EMAIL" }],}3. Register the provider
Section titled “3. Register the provider”Add the plugin to your emdash() integration:
import { d1, r2 } from "@emdash-cms/cloudflare";import { cloudflareEmail } from "@emdash-cms/cloudflare/plugins";
emdash({ database: d1({ binding: "DB" }), storage: r2({ binding: "MEDIA" }), plugins: [ cloudflareEmail({ from: { email: "cms@mails.example.com", name: "My Site CMS" }, replyTo: "hello@example.com", // optional binding: "EMAIL", // optional, defaults to "EMAIL" }), ],}),4. Activate and select it
Section titled “4. Activate and select it”Deploy, then activate the plugin under Admin → Extensions and choose it as the provider under Settings → Email.
Options
Section titled “Options”| Option | Type | Default | Description |
|---|---|---|---|
from | string | { email, name? } | — (required) | Sender address on a domain onboarded for Email Sending. |
replyTo | string | — | Optional Reply-To, useful when from is a no-reply subdomain address. |
binding | string | "EMAIL" | Name of the send_email binding in wrangler.jsonc. |
Environment Variables
Section titled “Environment Variables”Recommended: encryption key
Section titled “Recommended: encryption key”EMDASH_ENCRYPTION_KEY is the key for encrypting plugin secrets at
rest (webhook tokens, Turnstile keys, etc.). The key is validated on
startup; plugin secret encryption uses it once enabled. Set it on
every deployment so secrets are protected without a later config
change.
The key is provided by you and never stored in the database; only encrypted ciphertext is. Losing it means losing every secret encrypted with it.
Generate a key and store it as a Worker secret with the following commands:
npx emdash secrets generatewrangler secret put EMDASH_ENCRYPTION_KEYOptional: stable-value overrides
Section titled “Optional: stable-value overrides”EmDash auto-generates the preview HMAC secret and commenter-IP hash salt and persists them in the database on first use. The env vars below are overrides for cases where you need to pin the value yourself — for example, when a preview Worker in a separate process needs to share the secret with your main site.
| Variable | Purpose |
|---|---|
EMDASH_PREVIEW_SECRET | Override for the auto-generated preview HMAC secret. |
EMDASH_IP_SALT | Override for the auto-generated commenter-IP hash salt. |
EMDASH_AUTH_SECRET | Optional. If set, it is used as the IP-salt source (unless EMDASH_IP_SALT is also set, which takes precedence), keeping commenter-IP hashes stable for installs that already rely on it. Leave it unset for a new deployment. |
Access environment variables in your configuration using import.meta.env or the Cloudflare env binding.
For the complete inventory of every secret EmDash uses — including storage locations, rotation steps, and what breaks when a key is lost — see Secrets & Key Management.
Preview Deployments
Section titled “Preview Deployments”Deploy a preview branch:
wrangler deploy --env previewAdd an environment section to wrangler.jsonc:
{ "env": { "preview": { "d1_databases": [ { "binding": "DB", "database_name": "emdash-db-preview", }, ], }, },}Troubleshooting
Section titled “Troubleshooting””D1 binding not found”
Section titled “”D1 binding not found””Verify the binding name in wrangler.jsonc matches your database configuration:
// Must match: d1({ binding: "DB" })"binding": "DB"“R2 binding not found”
Section titled ““R2 binding not found””Check that the R2 bucket is correctly bound:
// Must match: r2({ binding: "MEDIA" })"binding": "MEDIA"Migration errors
Section titled “Migration errors”If you see schema errors, tail the Worker logs (wrangler tail) and reproduce the error to capture the underlying message — then file an issue with that output.