Cloudflare-hosted EmDash with D1 + R2 + Access

Comprehensive deployment and operations guide

Version context: This guide is written against the public EmDash preview and current Cloudflare docs as of 2026-04-03.
Target stack: EmDash on Cloudflare Workers with D1 for database, R2 for media/assets, and Cloudflare Access for private access control.
Primary use case: private wiki, internal knowledge base, editorial site, or controlled-access content platform.


Table of contents

  1. What this stack is
  2. Recommended architecture
  3. When to use this pattern
  4. How the pieces fit together
  5. Prerequisites
  6. Provisioning order
  7. Step 1 — Create or obtain an EmDash project
  8. Step 2 — Provision D1
  9. Step 3 — Provision R2
  10. Step 4 — Configure Wrangler bindings and routes
  11. Step 5 — Configure EmDash in Astro
  12. Step 6 — Protect the site with Cloudflare Access
  13. Step 7 — Choose your privacy model
  14. Step 8 — Local development and testing
  15. Step 9 — Media, URLs, and file access strategy
  16. Step 10 — Security hardening
  17. Step 11 — Deployment workflow
  18. Step 12 — Operations, observability, and maintenance
  19. Step 13 — Backup and recovery planning
  20. Step 14 — Migration notes for MediaWiki content
  21. Step 15 — Troubleshooting
  22. Appendix A — Example files
  23. Appendix B — Mermaid diagrams
  24. Appendix C — Reference links

What this stack is

This deployment pattern uses:

EmDash’s public project and docs show that it can run on Cloudflare (D1 + R2 + Workers), and its configuration reference includes first-class examples for:

The important practical takeaway is this:

For a private EmDash deployment, you should think in two layers:

  1. Edge access control: Cloudflare Access protects the hostname before the request reaches EmDash.
  2. Application auth/RBAC: EmDash decides which authenticated users can administer or edit content.

flowchart LR
    U[User browser] --> A[Cloudflare Access]
    A --> W[Cloudflare Worker running EmDash]
    W --> D[(Cloudflare D1)]
    W --> R[(Cloudflare R2)]
    W --> O[Observability / Logs / Analytics]

    I[Identity Provider<br/>Google / GitHub / Okta / One-time PIN] --> A

Why this architecture is the safest default


When to use this pattern

This stack is a strong fit when you want:

It is less ideal when you need:


How the pieces fit together

sequenceDiagram
    participant User
    participant Access as Cloudflare Access
    participant Worker as EmDash Worker
    participant D1 as D1 Database
    participant R2 as R2 Bucket

    User->>Access: Request https://wiki.example.com
    Access->>User: Login challenge / SSO redirect
    User->>Access: Successful authentication
    Access->>Worker: Forward request with Access JWT/header
    Worker->>Worker: Validate Access context / app auth
    Worker->>D1: Read page content, schema, roles
    Worker->>R2: Fetch media objects if needed
    Worker-->>User: Rendered page or admin UI

Prerequisites

Before you start, have:

Plan caveat: sandboxed plugins

EmDash’s public README says secure sandboxed plugins depend on Dynamic Workers, which are currently only available on paid Cloudflare accounts. If you are on a free plan, the documented workaround is to disable the worker_loaders block. For a private wiki, this usually means one of two things:


Provisioning order

Use this order to minimize rework:

flowchart TD
    A[Create EmDash project] --> B[Create D1 database]
    B --> C[Create R2 bucket]
    C --> D[Configure Wrangler bindings]
    D --> E[Configure Astro / EmDash]
    E --> F[Deploy Worker]
    F --> G[Attach custom domain]
    G --> H[Create Cloudflare Access app]
    H --> I[Set AUD / team domain in env]
    I --> J[Test end-to-end]

Step 1 — Create or obtain an EmDash project

Option A: scaffold a new project

npm create emdash@latest

Option B: deploy from the public repo pattern

The public EmDash repo includes a Cloudflare demo and a “Deploy to Cloudflare” path. Even if you start from that, expect to customize:

For a real private deployment:

  1. scaffold a project
  2. commit it into your own repo
  3. explicitly configure:
    • D1
    • R2
    • Access
    • custom domain
    • secrets and env vars

Step 2 — Provision D1

Cloudflare D1 is the database layer for EmDash on Workers.

Create the database

npx wrangler d1 create emdash-prod

Record:

Bind it in wrangler.jsonc

{
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "emdash-prod",
      "database_id": "YOUR_DATABASE_ID"
    }
  ]
}

Migration model

EmDash’s config reference states that D1 requires migrations via Wrangler CLI and that DDL is not allowed at runtime. Cloudflare’s D1 migration docs also describe a migration workflow centered on a migrations/ directory.

That means you should treat schema changes as versioned migration files, not as ad hoc runtime mutations.

Suggested migration workflow

flowchart LR
    A[Edit schema / upgrade EmDash] --> B[Generate or author SQL migration]
    B --> C[Commit migration to repo]
    C --> D[Apply migration in staging]
    D --> E[Validate app]
    E --> F[Apply migration in production]

Good practices for D1

Read-replica mode

EmDash’s config reference exposes:

d1({ binding: "DB", session: "auto" })

That mode uses the D1 Sessions API for read-replica behavior and bookmark-based read-your-writes consistency for authenticated users. For content-heavy, read-biased sites, this is a reasonable production default.


Step 3 — Provision R2

R2 stores uploaded media and other objects.

Create a bucket

Use the dashboard or Wrangler command set for R2 to create a bucket such as:

Bind the bucket in wrangler.jsonc

{
  "r2_buckets": [
    {
      "binding": "MEDIA",
      "bucket_name": "emdash-media-prod"
    }
  ]
}

Development behavior

Cloudflare’s R2 docs note that wrangler dev uses a local R2 simulation by default, and objects stored there live in .wrangler/state on your machine. If you want to use the real remote bucket during local development, add:

{
  "r2_buckets": [
    {
      "binding": "MEDIA",
      "bucket_name": "emdash-media-prod",
      "remote": true
    }
  ]
}

Media-path design

You should decide whether uploads are:

For an internal wiki, the safest default is:


Step 4 — Configure Wrangler bindings and routes

Below is a practical starting point for a Cloudflare-hosted EmDash project.

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "emdash-private-wiki",
  "main": "./src/worker.ts",
  "compatibility_date": "2026-04-03",
  "compatibility_flags": ["nodejs_compat", "disable_nodejs_process_v2"],

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

  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "emdash-prod",
      "database_id": "YOUR_D1_DATABASE_ID"
    }
  ],

  "r2_buckets": [
    {
      "binding": "MEDIA",
      "bucket_name": "emdash-media-prod"
    }
  ],

  "worker_loaders": [
    {
      "binding": "LOADER"
    }
  ],

  "observability": {
    "enabled": true
  }
}

Binding map

flowchart TD
    WR[wrangler.jsonc] --> DB[DB binding -> D1]
    WR --> MEDIA[MEDIA binding -> R2]
    WR --> LOADER[LOADER binding -> Dynamic Workers / sandboxed plugins]
    WR --> ROUTE[Route / custom_domain]
    WR --> OBS[Observability]

Notes


Step 5 — Configure EmDash in Astro

EmDash’s configuration reference shows the generic integration shape:

Its Cloudflare demo config shows the Cloudflare-native version using D1, R2, and an Access helper.

// astro.config.mjs
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import emdash from "emdash/astro";

// Depending on your installed package versions / exports:
import { d1, r2, access, sandbox, cloudflareCache } from "@emdash-cms/cloudflare";

export default defineConfig({
  output: "server",
  adapter: cloudflare(),

  integrations: [
    emdash({
      database: d1({
        binding: "DB",
        session: "auto"
      }),

      storage: r2({
        binding: "MEDIA"
      }),

      auth: access({
        teamDomain: "your-team.cloudflareaccess.com",
        autoProvision: true,
        defaultRole: 30
      }),

      sandboxRunner: sandbox(),

      plugins: [],
      sandboxed: []
    })
  ],

  experimental: {
    cache: {
      provider: cloudflareCache()
    }
  }
});

Generic configuration-reference style example

If your installed version exposes the generic auth configuration instead of the helper wrapper, the public config reference shows this shape:

import { defineConfig } from "astro/config";
import emdash, { r2 } from "emdash/astro";
import { d1 } from "emdash/db";

export default defineConfig({
  integrations: [
    emdash({
      database: d1({ binding: "DB", session: "auto" }),

      storage: r2({ binding: "MEDIA" }),

      auth: {
        cloudflareAccess: {
          teamDomain: "your-team.cloudflareaccess.com",
          audience: "YOUR_AUD_TAG",
          autoProvision: true,
          defaultRole: 30,
          syncRoles: false,
          roleMapping: {
            "Admins": 50,
            "Editors": 40
          }
        }
      }
    })
  ]
});

What the Access auth block means

Important auth behavior

The config reference explicitly states:

When cloudflareAccess is configured, it becomes the exclusive auth method. Passkeys, OAuth, magic links, and self-signup are disabled.

That is usually the right choice for an internal/private deployment.


Step 6 — Protect the site with Cloudflare Access

For a Cloudflare-hosted EmDash site on a public hostname, the best model is usually:

  1. deploy the Worker on a custom domain
  2. create a self-hosted Access application for that hostname
  3. enforce Access policies for the audience you want
  4. wire EmDash to the same Access identity context

Why use Access even if EmDash has auth

Because the two systems solve different problems:

Access topology

flowchart TD
    Hostname[wiki.example.com] --> AccessApp[Cloudflare Access application]
    AccessApp --> Policy[Allow / Block / Include rules]
    Policy --> IdP[Identity provider groups]
    AccessApp --> EmDash[EmDash Worker]
    EmDash --> RBAC[EmDash roles: Admin / Editor / Author / Contributor]

Basic Access setup pattern

For a public hostname application:

Getting the AUD tag

Cloudflare’s Access docs say each Access application has a unique Application Audience (AUD) Tag. Copy it from:

That AUD is what you place into your EmDash config or environment.

Use secrets / environment vars for sensitive values:

# Example names - choose a consistent convention
CF_ACCESS_AUDIENCE=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
CF_ACCESS_TEAM_DOMAIN=your-team.cloudflareaccess.com
EMDASH_AUTH_SECRET=replace_me
EMDASH_PREVIEW_SECRET=replace_me

Step 7 — Choose your privacy model

There are several valid patterns.

Pattern A — Entire site private

Best for:

flowchart LR
    User --> Access[Access gate on entire hostname]
    Access --> Site[All EmDash pages and admin]

Recommendation: this is the cleanest private-wiki pattern.

Pattern B — Public content, private admin

Best for:

Possible approaches:

  1. split hostnames:
    • www.example.com for public site
    • admin.example.com for protected admin
  2. or use more granular app routing/policies if you are certain about your policy boundaries

Recommendation: prefer separate admin subdomain if privacy is important. It is simpler to reason about and audit.

Pattern C — Private staging, public production

Best for:

Use Access on:

Decision diagram

flowchart TD
    A[What are you building?] --> B{Should all readers be authenticated?}
    B -->|Yes| C[Protect full hostname with Access]
    B -->|No| D{Should admin be isolated?}
    D -->|Yes| E[Use separate admin subdomain + Access]
    D -->|No| F[Use public site with app-level auth only]

For a private wiki, choose Pattern A.


Step 8 — Local development and testing

D1 local development

Cloudflare D1 docs support local development with Wrangler. Use local state for dev and separate remote resources for staging/production.

R2 local development

By default, wrangler dev uses a local R2 simulation. This is useful for safe local iteration.

Practical local workflow

pnpm install
pnpm dev

If your project follows the demo pattern, local development typically runs through Astro with the Cloudflare runtime.

Suggested environment separation

Environment Runtime Database Media Access
Local dev wrangler dev / Astro dev Local D1 simulation or separate dev DB Local R2 simulation Usually disabled or mocked
Staging Worker on custom subdomain Staging D1 Staging R2 Enabled
Production Worker on final domain Prod D1 Prod R2 Enabled

Environment diagram

flowchart LR
    L[Local] --> S[Staging]
    S --> P[Production]

    L --> LD1[(Local / dev D1)]
    L --> LR2[(Local / dev R2)]

    S --> SD1[(Staging D1)]
    S --> SR2[(Staging R2)]

    P --> PD1[(Prod D1)]
    P --> PR2[(Prod R2)]

Testing checklist

Before go-live, verify:


Step 9 — Media, URLs, and file access strategy

This is one of the most important design choices for a private deployment.

Option 1 — Public object URLs

Use when:

Public URL example in EmDash config:

storage: r2({
  binding: "MEDIA",
  publicUrl: "https://pub-xxxx.r2.dev"
})

Option 2 — Access-controlled site, indirect media access

Use when:

Option 3 — Bucket/path protection with Access

Cloudflare’s R2 docs include a guide for protecting an R2 bucket with Access. This is useful when you need additional protection for object access paths.

For a private wiki, prefer this order of safety:

  1. protect the site hostname with Access
  2. serve content and media through app-controlled paths where practical
  3. only expose raw bucket/public URLs when you deliberately want that behavior

Media flow

flowchart LR
    U[Authenticated user] --> A[Cloudflare Access]
    A --> E[EmDash Worker]
    E --> R[(R2 bucket)]
    E --> U

Step 10 — Security hardening

Core controls

Role strategy

A sensible default for a private wiki:

IdP group mapping

Use Cloudflare Access groups from your IdP and map them to EmDash roles.

Example:

roleMapping: {
  "Wiki-Admins": 50,
  "Wiki-Editors": 40,
  "Wiki-Authors": 30
}

Security boundary diagram

flowchart TD
    Internet --> AccessGate[Access policy]
    AccessGate --> Worker[EmDash Worker]
    Worker --> AppRoles[EmDash RBAC]
    Worker --> D1[(D1)]
    Worker --> R2[(R2)]

Path to keep simple

For an internal deployment, the safest version is usually:


Step 11 — Deployment workflow

flowchart LR
    Dev[Developer change] --> Git[Commit to repo]
    Git --> CI[Build / test]
    CI --> Migrate[Apply D1 migration to staging]
    Migrate --> DeployStage[Deploy staging Worker]
    DeployStage --> Verify[Manual + automated verification]
    Verify --> Promote[Apply prod migration]
    Promote --> DeployProd[Deploy production Worker]

Production checklist

One-click deployment

Cloudflare’s Deploy-to-Cloudflare flow is useful for bootstrapping, but for a real private site you should still explicitly review:


Step 12 — Operations, observability, and maintenance

What to monitor

Suggested maintenance rhythm

Operational event flow

flowchart TD
    Alert[Alert or user report] --> Triage[Triage]
    Triage --> Scope[Is it Access, Worker, D1, or R2?]
    Scope --> Fix[Mitigate or roll back]
    Fix --> Verify[Verify app path and admin path]
    Verify --> Postmortem[Record lessons]

Step 13 — Backup and recovery planning

This stack is modern, but you still need classic backup discipline.

Minimum recovery plan

Document:

Recovery objectives to define

Recovery diagram

flowchart LR
    Incident[Failure / corruption / bad deploy] --> Decide{What failed?}
    Decide -->|Schema/data| RestoreDB[Recover D1 / re-apply migrations]
    Decide -->|Media| RestoreR2[Recover R2 objects]
    Decide -->|App config| Redeploy[Redeploy known-good Worker]
    Decide -->|Access policy| ReapplyAccess[Restore Access app/policies]

Practical advice


Step 14 — Migration notes for MediaWiki content

If your final target is a private wiki, this Cloudflare stack can host it well. But MediaWiki migration remains a custom migration project.

What maps well

What needs custom handling

Suggested migration path

flowchart LR
    MW[MediaWiki exports] --> Convert[Transform content]
    Convert --> Model[Model content in EmDash collections]
    Model --> Import[Import into EmDash / D1]
    Import --> Review[Editorial QA]
    Review --> Publish[Expose via private Access-protected site]

Step 15 — Troubleshooting

1) The site deploys but admin login fails

Check:

2) Uploads fail

Check:

3) Changes are not visible after write

Check:

4) Access blocks everyone

Check:

5) Sandboxed plugins do not work

Check:

6) Custom domain does not route correctly

Check:

Troubleshooting decision tree

flowchart TD
    Start[Issue observed] --> Kind{What kind of issue?}
    Kind -->|401/403| AccessIssue[Access / AUD / policy]
    Kind -->|500| WorkerIssue[Worker runtime / app config]
    Kind -->|Missing content| D1Issue[D1 binding / migration / environment]
    Kind -->|Missing media| R2Issue[R2 binding / bucket / URL path]
    Kind -->|Plugin problem| PluginIssue[Loader / plan / sandbox config]

Appendix A — Example files

Example wrangler.jsonc

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "emdash-private-wiki",
  "main": "./src/worker.ts",
  "compatibility_date": "2026-04-03",
  "compatibility_flags": ["nodejs_compat", "disable_nodejs_process_v2"],

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

  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "emdash-prod",
      "database_id": "YOUR_DATABASE_ID"
    }
  ],

  "r2_buckets": [
    {
      "binding": "MEDIA",
      "bucket_name": "emdash-media-prod"
    }
  ],

  "worker_loaders": [
    {
      "binding": "LOADER"
    }
  ],

  "observability": {
    "enabled": true
  }
}

Example astro.config.mjs

import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import emdash from "emdash/astro";
import { d1, r2, access, sandbox, cloudflareCache } from "@emdash-cms/cloudflare";

export default defineConfig({
  output: "server",
  adapter: cloudflare(),

  integrations: [
    emdash({
      database: d1({
        binding: "DB",
        session: "auto"
      }),

      storage: r2({
        binding: "MEDIA"
      }),

      auth: access({
        teamDomain: process.env.CF_ACCESS_TEAM_DOMAIN,
        autoProvision: true,
        defaultRole: 30
      }),

      sandboxRunner: sandbox(),

      plugins: [],
      sandboxed: []
    })
  ],

  experimental: {
    cache: {
      provider: cloudflareCache()
    }
  }
});

Example generic Access config block

auth: {
  cloudflareAccess: {
    teamDomain: process.env.CF_ACCESS_TEAM_DOMAIN,
    audience: process.env.CF_ACCESS_AUDIENCE,
    autoProvision: true,
    defaultRole: 30,
    syncRoles: false,
    roleMapping: {
      "Wiki-Admins": 50,
      "Wiki-Editors": 40,
      "Wiki-Authors": 30
    }
  }
}

Appendix B — Mermaid diagrams

This guide includes these Mermaid diagram types:

  1. architecture diagram
  2. request sequence
  3. provisioning flow
  4. D1 migration flow
  5. bindings map
  6. Access topology
  7. privacy-mode decision flow
  8. environment progression
  9. media flow
  10. security boundary
  11. deployment workflow
  12. operations incident flow
  13. recovery workflow
  14. migration workflow
  15. troubleshooting decision tree

If your Markdown renderer does not support Mermaid, render the document in GitHub, GitLab, Obsidian with Mermaid enabled, or a docs stack that supports Mermaid code fences.


EmDash

Cloudflare Workers / domains

Cloudflare D1

Cloudflare R2

Cloudflare Access / Zero Trust


Final recommendations

If your target is a private EmDash wiki on Cloudflare, this is the most robust operating model:

  1. deploy EmDash as a Worker on a custom domain
  2. use D1 for content and site state
  3. use R2 for uploads
  4. protect the entire hostname with Cloudflare Access
  5. wire EmDash to Cloudflare Access for app-level user provisioning and role mapping
  6. keep staging and production fully separate
  7. use sandboxed plugins only if your Cloudflare plan supports the required Dynamic Workers model

That combination gives you: