Skip to content
Wrenches and hand tools hanging on a workshop wall
Roger Stringer1 min read

Deploying EmDash on Cloudflare: D1, R2, KV and a custom domain

The exact wrangler setup this site runs on, plus two gotchas: passkeys are tied to your hostname, and routes switch off workers.dev.

EmDash has two deployment shapes. On Node.js you use SQLite, libSQL or PostgreSQL plus the filesystem or S3. On Cloudflare Workers, D1 stores content and R2 stores media. This site uses the Cloudflare path, and here's the setup.

Scaffold for Cloudflare

bun create emdash@latest my-site --template cloudflare:blog --pm bun

The Astro config wires EmDash to the bindings by name:

astro.config.mjs
import cloudflare from "@astrojs/cloudflare";
import { d1, r2 } from "@emdash-cms/cloudflare";
import emdash from "emdash/astro";

export default defineConfig({
  output: "server",
  adapter: cloudflare(),
  integrations: [
    emdash({
      siteUrl: "https://example.com",
      database: d1({ binding: "DB", session: "auto" }),
      storage: r2({ binding: "MEDIA" }),
    }),
  ],
});

Pin your resources

Create the database, bucket and session namespace yourself and pin their IDs in wrangler.jsonc. That keeps every deploy pointed at the same data.

bunx wrangler d1 create my-site
bunx wrangler r2 bucket create my-site-media
bunx wrangler kv namespace create my-site-session
wrangler.jsonc
{
  "d1_databases": [
    { "binding": "DB", "database_name": "my-site", "database_id": "<id>" }
  ],
  "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "my-site-media" }],
  "kv_namespaces": [{ "binding": "SESSION", "id": "<id>" }]
}

The SESSION namespace backs Astro sessions, which the admin login uses.

Secrets

Keep EMDASH_ENCRYPTION_KEY out of your repo and your terminal history. Pipe it straight from .env into Wrangler:

grep EMDASH_ENCRYPTION_KEY .env | cut -d= -f2- | bunx wrangler secret put EMDASH_ENCRYPTION_KEY

Gotcha 1: passkeys are bound to the hostname

EmDash admin logins use passkeys, and a passkey belongs to one hostname. Decide on the final domain before anyone registers, set it as siteUrl, and 301 every other host (www, *.workers.dev) to it in middleware.

Gotcha 2: routes turn off workers.dev

The moment you add custom-domain routes, Wrangler disables the workers.dev URL unless you say otherwise:

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

Ship it

bun run deploy

That runs astro build && wrangler deploy. Open /_emdash/admin on your real domain, register your passkey, and you're live.