Skip to content

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.

  • A Cloudflare account
  • Wrangler CLI installed (npm install -g wrangler)
  • Authenticated with Cloudflare (wrangler login)

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.

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

Update your Astro configuration to use D1 and R2:

astro.config.mjs
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import emdash from "emdash/astro";
import { d1, r2 } from "@emdash-cms/cloudflare";
export default defineConfig({
output: "server",
adapter: cloudflare(),
integrations: [
emdash({
database: d1({ binding: "DB" }),
storage: r2({ binding: "MEDIA" }),
}),
],
});

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.

Deploy to Cloudflare Workers:

Terminal window
wrangler deploy

Your site is now live at https://my-emdash-site.<your-subdomain>.workers.dev.

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.

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

Add a custom domain in the Cloudflare dashboard:

  1. Go to Workers & Pages > your worker
  2. Click Custom Domains > Add Custom Domain
  3. Enter your domain and follow the DNS setup instructions

To serve media directly from R2 (recommended for performance):

  1. In the Cloudflare dashboard, go to R2 > your bucket
  2. Click Settings > Public access
  3. Enable public access and note the public URL
  4. Update your storage config:
astro.config.mjs
storage: r2({
binding: "MEDIA",
publicUrl: "https://pub-xxx.r2.dev"
}),

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:

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

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:

Terminal window
npx emdash secrets generate
wrangler secret put EMDASH_ENCRYPTION_KEY

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.

VariablePurpose
EMDASH_PREVIEW_SECRETOverride for the auto-generated preview HMAC secret.
EMDASH_IP_SALTOverride for the auto-generated commenter-IP hash salt.
EMDASH_AUTH_SECRETOptional. 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.

Deploy a preview branch:

Terminal window
wrangler deploy --env preview

Add an environment section to wrangler.jsonc:

{
"env": {
"preview": {
"d1_databases": [
{
"binding": "DB",
"database_name": "emdash-db-preview",
},
],
},
},
}

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.