Skip to content

Deploy to Cloudflare

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.

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

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:

wrangler.jsonc
{
"$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.

The following Astro configuration uses the D1 and R2 bindings.

astro.config.mjs
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.

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

src/worker.ts
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 the site once to let Wrangler provision the named D1 database and R2 bucket. Wrangler uses the local login created by pnpm wrangler login.

Terminal window
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.

Until the setup wizard has been completed, EmDash also applies the schema and structure of a seed file once, on the first request. Sample content from the seed is applied only when an administrator chooses it in the setup wizard. 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.

On Cloudflare, EmDash publishes scheduled entries only from the Worker’s scheduled handler, which Cloudflare calls on the schedule of a Cron Trigger. The deployed Worker needs both of the following:

  • A Cron Trigger in the triggers.crons list of wrangler.jsonc, as shown in Configure bindings.
  • The scheduled handler from @emdash-cms/cloudflare/worker, exported from the Worker entry point that main points to, as shown in Add the Worker entry point.

If either is missing, scheduled entries do not go live. In local development with astro dev, EmDash publishes scheduled entries on a timer, so a schedule that works locally does not confirm the deployed configuration.

Run emdash doctor from the project root to check the Cron Trigger and the scheduled handler in the local project files:

Terminal window
pnpm emdash doctor

When emdash doctor finds both, the output includes a passing scheduler wiring check. A failed scheduler check describes the problem and prints the fix. The check recognizes the Worker entry point shown on this page and the one-line re-export it prints as a fix, so an entry point written another way can fail the check even when it works.

If no scheduler check appears, emdash doctor found no Wrangler configuration in the current directory, or it found neither the Cron Trigger nor the scheduled handler. On a D1 project, the database check fails because it looks for a local SQLite file (./data.db by default). That failure does not affect the scheduler checks.

Deploy after adding or changing the Cron Trigger or the scheduled handler. Cloudflare can take up to 15 minutes to start running a new or changed Cron Trigger. Once the trigger starts running, EmDash publishes entries whose scheduled time has already passed.

To watch the trigger, run pnpm wrangler tail. Each run prints a line such as "* * * * *" @ 9/29/2026, 10:01:00 AM - Ok. Ok means the run ended without an uncaught error. EmDash catches and logs publishing errors, so Ok does not confirm that entries were published. A run that publishes entries also logs a message such as [scheduled] Published 1 scheduled item(s), and errors from the run appear in the same output.

After deployment, the admin dashboard shows Scheduled publishing needs attention when a scheduled entry is past its scheduled time and no scheduler run has completed in the last five minutes. If the Cron Trigger runs less often than every five minutes, the notice can also appear between runs until the next run publishes the entry.

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.

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:

astro.config.mjs
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.

Cloudflare’s Workers Cache puts an edge cache in front of your Worker: matching requests are served without running your Worker at all.

  1. Use Astro’s Cloudflare cache provider so route rules and Astro.cache set cache headers and invalidation uses cache.purge().

    astro.config.mjs
    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.

Workers Caching is separate from the object cache (objectCache: kvCache({ binding: "CACHE" })), which caches database query results in KV under the Worker.

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:

wrangler.jsonc
{
"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. To make the new domain the address in sitemaps and search metadata, and in emails and plugins when siteUrl, EMDASH_SITE_URL, or SITE_URL is not set, use Change domain in Settings > General; see Move to a New Domain.

Without siteUrl, EMDASH_SITE_URL, or SITE_URL, setup records the address it runs on as the site’s origin, and passkeys created during setup only work on that address. Run setup on the production domain, or set EMDASH_SITE_URL to it before setup. Setting EMDASH_SITE_URL to another domain after setup moves sign-in to that domain, and passkeys created on the old address stop working there.

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:

astro.config.mjs
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.

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:

wrangler.jsonc
{
"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 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:

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,
},
}),
}),

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

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:

wrangler.jsonc
{
"send_email": [{ "name": "EMAIL" }],
}
astro.config.mjs
import { cloudflareEmail } from "@emdash-cms/cloudflare/plugins";
emdash({
plugins: [
cloudflareEmail({
from: { email: "cms@mails.example.com", name: "My Site CMS" },
replyTo: "hello@example.com",
}),
],
}),

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. A plugin can set replyTo on an individual message, which overrides this option for that message.

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.

astro.config.mjs
import { aiSearch } from "@emdash-cms/cloudflare/plugins";
emdash({
plugins: [aiSearch()],
}),
wrangler.jsonc
{
"ai_search_namespaces": [{ "binding": "AI_SEARCH", "namespace": "default" }],
}

Expose the search route from the site:

src/pages/api/ai-search/search.ts
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:

src/layouts/Base.astro
---
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>

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 encrypts plugin settings declared as secrets. Set it before saving a plugin secret, and preserve it separately from D1 backups. During rotation, provide the new key first and retain older keys after commas until every plugin secret has been saved again. Secrets and key management describes rotation and recovery.

The plugin bridge reads this Worker secret binding directly. The generated admin settings route reads it through process.env. With nodejs_compat, Cloudflare populates process.env by default for compatibility dates on or after 2025-04-01. Projects pinned to an earlier date must also add nodejs_compat_populate_process_env before saving encrypted settings.

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.

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

Terminal window
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:

wrangler.jsonc
{
"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.

Terminal window
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.

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.

Verify the binding name in wrangler.jsonc matches your database configuration:

// Must match: d1({ binding: "DB" })
"binding": "DB"

Check that the R2 bucket is correctly bound:

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

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.