Deploy to Cloudflare

On this page

This guide deploys an EmDash site to Cloudflare Workers with D1 for its database and R2 for media. Start with an EmDash Cloudflare template or apply the same configuration to an existing Astro site.

Prerequisites

  • A Cloudflare account
  • The project dependencies installed
  • Wrangler authenticated with Cloudflare (pnpm wrangler login)

Configure bindings

The Cloudflare templates include the complete Worker entry point and named D1 and R2 bindings. On the first deploy, Wrangler creates either resource if its configured name does not already exist. Keep the names in wrangler.jsonc; Wrangler reconnects later deploys to the same resources.

The template uses the following bindings:

{
	"$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": ["* * * * *"] },
}

The DB, MEDIA, and LOADER names must match the EmDash adapters. The Cron Trigger runs scheduled publishing, plugin tasks, backups, and maintenance. See Plugin sandbox if the site uses sandboxed plugins.

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, 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(),
		}),
	],
});

If the site does not use marketplace, registry, or sandboxed plugins, omit sandboxRunner and the LOADER binding.

Add the Worker entry point

The Worker entry point connects Astro to the Cron Trigger and exports the plugin bridge:

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

export { PluginBridge };

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

The PluginBridge export is harmless when no sandboxed plugin is installed. Keep it if the same project may enable plugins later.

To run general maintenance on a schedule other than every minute, pass the same Cron expression to createScheduledHandler({ generalCron: "..." }) and to triggers.crons. If they differ, the handler logs and ignores the unexpected trigger.

Build and deploy

Build and deploy the site once to let Wrangler provision the named D1 database and R2 bucket. Wrangler uses the local login created by pnpm wrangler login.

pnpm build
pnpm wrangler deploy

With the default auto migration mode, EmDash applies pending core migrations when the deployed Worker receives its first request. Use Manage core database migrations when a deployment pipeline must apply migrations before new code receives traffic or when you need to inspect, check, or recover a migration.

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.

Place the Worker near D1

Cloudflare runs a Worker near the visitor by default. EmDash server-rendered requests make several D1 round trips, so use Targeted Placement to run the Worker near the D1 primary and make those requests faster.

Wrangler accepts placement.mode: "targeted" with exactly one selector: region, host, or hostname. Select the value that targets the D1 primary location, and add the resulting placement object to wrangler.jsonc. Do not enable D1 read replicas with Targeted Placement. Keep EmDash’s session setting at its default, "disabled", so reads and writes use the nearby primary.

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

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

  1. Use Astro’s Cloudflare cache provider so route rules and Astro.cache set cache headers and invalidation uses 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.
     },
    });

    The @astrojs/cloudflare adapter detects cacheCloudflare() and enables Workers Cache in the generated deployment configuration.

  2. Purge cached responses from Worker code with the platform API. This call does not need Cloudflare REST credentials.

    import { cache } from "cloudflare:workers";
    
    await cache.purge({ purgeEverything: true });
    // Or purge selected tags:
    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:

  1. Responses without a Cache-Control header are still cached. Workers Cache applies RFC 9111 heuristic freshness — a 200 without any header is cached for 2 hours. Give every custom route an explicit Cache-Control (use private, no-store for anything session-dependent).
  2. 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

Preferred: Workers CachingLegacy: cloudflareCache()
Config"cache": { "enabled": true } + cacheCloudflare() from @astrojs/cloudflare/cachecache: { provider: cloudflareCache() } from @emdash-cms/cloudflare
StoragePlatform Workers CachingCache API (caches.open / put / match)
Purgecache.purge() from cloudflare:workersZone REST POST /zones/{id}/purge_cache
SecretsNone for purgeCF_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 domains

The first deployment receives a workers.dev URL. The custom domain must already be an active domain managed by Cloudflare in the same account as the Worker. After the Worker responds successfully at its workers.dev URL, add the production domain as a Wrangler route:

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

Deploy again and verify both addresses. Keeping the workers.dev address available while testing DNS helps distinguish a routing problem from an application problem.

Public R2 access

By default, media is served through EmDash’s authenticated media route. If the bucket has a public custom domain, set that origin as publicUrl so generated media URLs use it:

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

Public bucket access applies to every reachable object, not only media. Automatic JSON backups use the backups/ prefix in the same storage backend, so do not expose that prefix through the public domain. Choose media storage explains the safe boundary.

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. If a site has 500 source images and requests one thumbnail size and one hero size for every image, those two parameter sets count as 1,000 transformed images for that month. 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

Cloudflare Access can replace passkey authentication with the identity provider attached to an Access application. The audience value is a secret runtime setting; keep it out of astro.config.mjs by naming its environment variable:

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

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

Set CF_ACCESS_AUDIENCE with pnpm wrangler secret put CF_ACCESS_AUDIENCE. The authentication guide explains user provisioning, default roles, and role synchronization.

Email

Production Workers have no default email delivery service. Magic-link sign-in, team invitations, and comment notifications return Email is not configured until an email plugin is active.

The Cloudflare email plugin uses a send_email binding. First onboard and verify the sender domain with Cloudflare Email Sending. Cloudflare rejects messages whose From address is not an accepted sender.

Add the binding and register the provider:

{
	"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]",
		}),
	],
}),

After deployment, activate the plugin under Extensions and select it under Settings → Email. Sending fails until the sender is accepted and the binding exists.

The plugin uses the binding named EMAIL unless its binding option names another one. If it is the only active email provider, EmDash selects it automatically. If more than one provider is active, choose the Cloudflare provider under Settings → Email. The optional replyTo address receives replies without changing the accepted From address.

The AI Search plugin needs both a native plugin registration and an ai_search_namespaces binding. After deploying them, open Cloudflare AI Search in the admin, choose the collections, and run Sync All Content. The initial sync indexes content published before the plugin was enabled; hooks keep later changes synchronized.

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

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

Expose the search route from the site:

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

Add the search interface to a layout. The trigger slot accepts a button that matches the site’s design:

---
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 secrets

Store secret values with pnpm wrangler secret put <NAME>. Do not put them in wrangler.jsonc or read them from build-time import.meta.env values.

EMDASH_ENCRYPTION_KEY does not currently encrypt plugin secrets or any other stored data. If it is set, EmDash checks its format during startup. A malformed value produces an operator-facing log message, but the site continues to handle requests. Plugin secrets remain plaintext in the database.

EmDash reads its secrets from process.env at runtime. Worker code reads bindings from env, imported from cloudflare:workers. Never read secrets through import.meta.env: Vite replaces those values at build time and can write them into the server bundle.

The preview HMAC secret and commenter-IP salt are generated and stored in the database unless you provide runtime overrides. Secrets and key management lists the exact variables, storage locations, and rotation effects.

Preview deployments

Named Wrangler environments do not inherit bindings. Create separate preview resources and write them to the preview environment before building:

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

The preview environment must repeat every binding the preview Worker uses. The core D1, R2, and sandbox bindings have this shape after Wrangler writes the resource identifiers:

{
	"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" }],
		},
	},
}

Use the preview UUID written by Wrangler. Repeat optional KV, AI Search, email, and other bindings when the preview uses those features. Add preview-only secrets with pnpm wrangler secret put <NAME> --env preview.

Build and deploy the preview environment. Its first request applies pending core migrations through the default auto mode.

pnpm build
pnpm wrangler deploy --env preview

Verify the preview URL, admin sign-in, media upload, and any optional binding before sharing it. Never point a preview binding at a production database or bucket.

Verify the deployment

After deployment, request one public page, sign in to /_emdash/admin, upload and retrieve a test media file, and confirm the scheduled handler appears in pnpm wrangler tail.

Troubleshooting

”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”

Check that the R2 bucket is correctly bound:

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

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.