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
- A Cloudflare account
- Wrangler CLI installed (
npm install -g wrangler) - Authenticated with Cloudflare (
wrangler login)
Configure Bindings
Create wrangler.jsonc in your project root with D1 and R2 bindings. Wrangler provisions both resources on the first deploy if they don’t already exist.
{
"$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",
},
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "emdash-media",
},
],
}
Configure EmDash
Update your Astro configuration to use D1 and R2:
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" }),
}),
],
});
First Boot
Database migrations run automatically on the first request after deployment, and on every subsequent boot if there’s anything new to apply.
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
On Cloudflare Workers, scheduled publishing, plugin cron, and maintenance tasks run from a Worker Cron Trigger. New Cloudflare templates include this setup automatically. If you are adding updating an existing project, export the EmDash Worker entry from @emdash-cms/cloudflare/worker:
export { default, PluginBridge } from "@emdash-cms/cloudflare/worker";
Then add a Cron Trigger to wrangler.jsonc:
{
"triggers": {
"crons": ["* * * * *"],
},
}
Deploy
Deploy to Cloudflare Workers:
wrangler deploy
Your site is now live at https://my-emdash-site.<your-subdomain>.workers.dev.
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
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 ("cache": { "enabled": true } in wrangler.jsonc) puts an edge cache in front of your Worker: matching requests are served without running your Worker at all. This works well with EmDash:
- EmDash admin and API responses send
Cache-Control: private, no-storeand are never stored. - Your public pages control their own caching through the
Cache-Controlheaders they return.
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.
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
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"
}),
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.
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
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
Declare a send_email binding in wrangler.jsonc:
{
"send_email": [{ "name": "EMAIL" }],
}
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: "[email protected]", name: "My Site CMS" },
replyTo: "[email protected]", // optional
binding: "EMAIL", // optional, defaults to "EMAIL"
}),
],
}),
4. Activate and select it
Deploy, then activate the plugin under Admin → Extensions and choose it as the provider under Settings → Email.
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
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 generate
wrangler secret put EMDASH_ENCRYPTION_KEY
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
Deploy a preview branch:
wrangler deploy --env preview
Add an environment section to wrangler.jsonc:
{
"env": {
"preview": {
"d1_databases": [
{
"binding": "DB",
"database_name": "emdash-db-preview",
},
],
},
},
}
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.