How to Set Up a Staging Environment in EmDash CMS

How to Set Up a Staging Environment in EmDash CMS

A staging environment matters most right before a destructive schema change — removing a field, restructuring a collection — where testing against a disposable copy of production is genuinely safer than finding out what breaks live. EmDash's own deployment docs describe exactly this pattern for Cloudflare Workers; the same idea generalizes cleanly to Node.js hosting.

Table of Contents
  1. The Cloudflare Workers Pattern (Documented)
  2. Rehearsing a Schema Change on Staging
  3. The Node.js Equivalent: Environment-Based Configuration
  4. Keeping Staging's Content Model in Sync
  5. Recovering From a Mistake on Staging (Where It's Meant to Happen)
  6. Frequently Asked Questions
  7. Does staging need to be Cloudflare-specific?
  8. How often should I refresh staging's data from production?
  9. Can I just test schema changes locally instead of on a real staging deployment?
  10. Does EmDash charge extra for a staging deployment?
  11. The Bottom Line

The Cloudflare Workers Pattern (Documented)

Add a `preview` environment block to `wrangler.jsonc`, with its own D1 database:

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

Copy production data into it — a straight export and re-import:

npx wrangler d1 export emdash-db --remote --output=./prod.sql
npx wrangler d1 execute emdash-db-preview --remote --file=./prod.sql
emdashkits.com

Deploy to the preview environment specifically:

npx wrangler deploy --env preview
emdashkits.com

That gives you a fully separate, live URL running against a real (point-in-time) copy of your production content — safe to test a risky schema change against before touching the real database.

Rehearsing a Schema Change on Staging

EmDash's `emdash schema` CLI commands talk to a running instance over its REST API, so the same commands that would modify production work identically against your preview URL first:

npx emdash schema remove-field posts legacy_field --url https://preview.your-site.workers.dev
emdashkits.com
  1. Run the schema change against the preview URL.
  2. Verify the site renders correctly and the admin panel behaves as expected.
  3. Only then, run the identical command against your production URL.
Removing a field deletes its column and data. Test schema changes against a preview environment before running them against production.
Read also:

The Node.js Equivalent: Environment-Based Configuration

For non-Cloudflare deployments, the same idea works with a config that picks a different database depending on environment — no wrangler-specific tooling required, just a conditional in `astro.config.mjs`:

import { sqlite, postgres } from "emdash/db";

const database = process.env.STAGING
  ? postgres({ connectionString: process.env.STAGING_DATABASE_URL })
  : postgres({ connectionString: process.env.DATABASE_URL });

export default defineConfig({
  integrations: [emdash({ database })],
});
emdashkits.com

Deploy this same codebase twice — once with `DATABASE_URL` pointed at your real production database, once with `STAGING=true` and `STAGING_DATABASE_URL` pointed at a separate database — and you have two independent EmDash instances sharing one codebase, exactly the same principle as the Cloudflare preview pattern.

Keeping Staging's Content Model in Sync

A staging environment is only useful if it reflects your real content model, not a stale one. EmDash's seed file is the mechanism for this — export the live schema (and optionally content) and commit it, so any fresh environment (staging included) bootstraps to the current model rather than an outdated one:

npx wrangler d1 export emdash-db --remote --output=./prod.sql
sqlite3 prod.db < prod.sql
npx emdash export-seed --database prod.db > .emdash/seed.json
emdashkits.com

Commit the updated `.emdash/seed.json` alongside the code that depends on the new schema. That way, a fresh staging environment bootstrapped from an empty database ends up with the same content model production actually runs, not the starter template's default.

Recovering From a Mistake on Staging (Where It's Meant to Happen)

  • A field was removed by mistake — restore from a D1 Time Travel point-in-time backup, or re-add the field and restore its values from an earlier `wrangler d1 export`.
  • A fresh environment bootstrapped with the wrong model — the embedded seed was stale; update `.emdash/seed.json`, rebuild, and re-bootstrap against an empty database.
  • Schema and templates disagree — order changes deliberately: additive schema changes (new collection, new optional field) deploy before the code using them; for removals, deploy the code that stops using a field first, then remove the field.

Frequently Asked Questions

Does staging need to be Cloudflare-specific?

No — the Cloudflare pattern is the one EmDash documents explicitly (using D1's export/import and Wrangler's environment blocks), but the underlying idea — a second deployment, a separate database — works identically on any Node.js hosting target using environment variables.

How often should I refresh staging's data from production?

Before any significant testing session, and definitely before rehearsing a destructive schema change — stale staging data can hide a real conflict that only shows up against current production content.

Can I just test schema changes locally instead of on a real staging deployment?

For non-destructive changes, local SQLite testing is often enough. For genuinely destructive changes (field removal, collection restructuring) on a production D1/Postgres database, testing against a real copy of production data specifically — not a local dev database with sample data — catches issues local testing can miss.

Does EmDash charge extra for a staging deployment?

No — EmDash itself has no licensing cost at any scale. A staging environment's cost is purely the infrastructure it runs on (a second D1 database and Worker, or a second Node.js deployment), which is typically minimal for a low-traffic preview environment.

The Bottom Line

A staging environment for EmDash is fundamentally a second deployment pointed at a separate database copy — documented explicitly for Cloudflare's D1/Wrangler workflow, and straightforward to replicate on any Node.js host with environment-based configuration. Use it specifically before destructive schema changes, and keep its seed file current so it reflects your real content model. See our guide to customizing content models for the schema changes worth rehearsing this way.

Share

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

TL;DR — A custom session-cookie login flow appeared to succeed on localhost (the OTP verified, the response looked fine) but every subsequent request to a login-gated page treated the visitor as logged out. Identical code worked fine on the live HTTPS site. The cookie's Secure attribute was hardcoded to true — and per the cookie spec, browsers never store or send a Secure cookie over a plain, non-HTTPS connection.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Fix
  4. Lessons learned

Diagnostic

Check the actual Set-Cookie response header and the browser's own cookie storage panel — on localhost over http://, the cookie is sent by the server but never actually stored by the browser.

Root cause

// before -- assumes the app is always served over HTTPS
setCookie("session", token, { secure: true, httpOnly: true });
emdashkits.com

A cookie config that quietly assumes "we're always on HTTPS" breaks the instant you test over plain HTTP, which local dev servers commonly are.

Read also:

Fix

// after -- derive secure from the actual request protocol
const isHttps = request.url.startsWith("https://");
setCookie("session", token, { secure: isHttps, httpOnly: true });
emdashkits.com

Lessons learned

  • Any Secure-flagged cookie needs to key off the real request scheme, not an assumption baked in once at cookie-creation time.
  • "Works in production, silently fails in local dev" is a strong signal to check cookie flags before anything else in an auth flow.
  • Check other cookies in the same codebase for the same hardcoded assumption — if one cookie has this bug, sibling cookies set the same way are worth auditing too.
Share
Previous Article

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Comments

Write a comment

Related Articles

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

TL;DR — GA4's gtag.js snippet was installed correctly, the page loaded with no visible JavaScript error, and GA4's real-time report still showed zero activity. The CMS's default Content-Security-Policy had no allowance for analytics domains and no config option to add one — so every request to Google's tracking endpoints was blocked at the browser level before it could fail loudly.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Lessons learned

Diagnostic

Check the browser's dedicated CSP violation reporting, not the regular console error list — CSP blocks are reported through their own channel, not thrown as normal script errors, so "no console errors" doesn't mean nothing was blocked.

Root cause

The CSP's script-src and connect-src directives had no entry for googletagmanager.com or google-analytics.com, and the CMS exposed no configuration surface to add one — the only way in was patching the CSP directives directly.

// patch-package: add analytics domains to the existing CSP directives
scriptSrc.push("https://www.googletagmanager.com");
connectSrc.push("https://www.google-analytics.com", "https://www.googletagmanager.com");
emdashkits.com
Read also:

Lessons learned

  • "No console errors" is not proof nothing was blocked — CSP violations live in their own reporting surface and are easy to miss if you're only scanning for red error text.
  • Before adding any third-party script tag to a site with a CSP already in place, check the CSP's directives first rather than assuming a silently-empty analytics dashboard means a snippet-installation mistake.
Share
Previous Article

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Next Article

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)