Database Options
EmDash supports multiple database backends. Choose based on your deployment target.
Overview
Section titled “Overview”| Database | Best For | Deployment |
|---|---|---|
| D1 | Cloudflare Workers | Edge, globally distributed |
| Hyperdrive | PostgreSQL on Cloudflare Workers | Edge, existing Postgres |
| PostgreSQL | Production Node.js | Any platform with Postgres |
| libSQL | Remote databases | Edge or Node.js |
| SQLite | Node.js, local dev | Single server |
Cloudflare D1
Section titled “Cloudflare D1”D1 is Cloudflare’s serverless SQLite database. Use it when deploying to Cloudflare Workers.
import { d1 } from "@emdash-cms/cloudflare";
export default defineConfig({ integrations: [ emdash({ database: d1({ binding: "DB" }), }), ],});Configuration
Section titled “Configuration”| Option | Type | Default | Description |
|---|---|---|---|
binding | string | — | D1 binding name from wrangler.jsonc |
session | string | "disabled" | Read replication mode (see below) |
bookmarkCookie | string | "__em_d1_bookmark" | Cookie name for session bookmarks |
{ "d1_databases": [ { "binding": "DB", "database_name": "emdash-db" } ]}[[d1_databases]]binding = "DB"database_name = "emdash-db"Read Replicas
Section titled “Read Replicas”D1 supports read replication to lower read latency for globally distributed sites. When enabled, read queries are routed to nearby replicas instead of always hitting the primary database.
EmDash uses the D1 Sessions API to manage this transparently. Enable it with the session option:
import { d1 } from "@emdash-cms/cloudflare";
export default defineConfig({ integrations: [ emdash({ database: d1({ binding: "DB", session: "auto", }), }), ],});Session Modes
Section titled “Session Modes”| Mode | Behavior |
|---|---|
"disabled" | No sessions. All queries go to primary. Default. |
"auto" | Anonymous requests read from the nearest replica. Authenticated users get read-your-writes consistency via bookmark cookies. |
"primary-first" | Like "auto", but the first query always goes to the primary. Use for sites with very frequent writes. |
How It Works
Section titled “How It Works”- Anonymous visitors get
first-unconstrained— reads go to the nearest replica for the lowest latency. Since anonymous users never write, they don’t need consistency guarantees. - Authenticated users (editors, authors) get bookmark-based sessions. After a write, a bookmark cookie ensures the next request sees at least that state.
- Write requests (
POST,PUT,DELETE) always start at the primary database. - Build-time queries (Astro content collections) bypass sessions entirely and use the primary directly.
libSQL
Section titled “libSQL”libSQL is a fork of SQLite that supports remote connections. Use it when you need a remote database without Cloudflare D1.
import { libsql } from "emdash/db";
export default defineConfig({ integrations: [ emdash({ database: libsql({ url: process.env.LIBSQL_DATABASE_URL, authToken: process.env.LIBSQL_AUTH_TOKEN, }), }), ],});Configuration
Section titled “Configuration”| Option | Type | Description |
|---|---|---|
url | string | Database URL (libsql://... or file:...) |
authToken | string | Runtime auth token for remote databases (optional for local) |
migrationAuthTokenEnv | string | Migration token variable name (default TURSO_AUTH_TOKEN) |
Local Development
Section titled “Local Development”Use a local libSQL file during development:
database: libsql({ url: "file:./data.db" });PostgreSQL
Section titled “PostgreSQL”PostgreSQL is supported for Node.js deployments that need a full relational database.
import { postgres } from "emdash/db";
export default defineConfig({ integrations: [ emdash({ database: postgres({ connectionString: process.env.DATABASE_URL, }), }), ],});Configuration
Section titled “Configuration”You can connect with a connection string or individual parameters:
// Connection stringdatabase: postgres({ connectionString: "postgres://user:password@localhost:5432/emdash",});
// Individual parametersdatabase: postgres({ host: "localhost", port: 5432, database: "emdash", user: "emdash", password: process.env.DB_PASSWORD, ssl: true,});| Option | Type | Description |
|---|---|---|
connectionString | string | PostgreSQL connection URL |
host | string | Database host |
port | number | Database port |
database | string | Database name |
user | string | Database user |
password | string | Database password |
ssl | boolean | Enable SSL |
pool.min | number | Minimum pool connections (default 0) |
pool.max | number | Maximum pool connections (default 10) |
migrationConnectionStringEnv | string | Migration connection-string variable name (default DATABASE_URL) |
Database role requirements
Section titled “Database role requirements”EmDash creates and updates its own PostgreSQL tables. Core migrations create and alter system and collection tables, content types create ec_* tables, and adding or removing a field alters its collection table. The configured PostgreSQL role therefore needs schema authority for the lifetime of the site, not only during initial setup.
Use one canonical role for EmDash. It needs:
CONNECTon the database;USAGEandCREATEon the active schema;- ownership of every EmDash table and function, either directly or through membership with
INHERITin the owning role; and SELECT,INSERT,UPDATE, andDELETEon those tables.
It does not need to be a superuser, have CREATEDB or CREATEROLE, or create extensions. PostgreSQL does not provide an ALTER or DROP table grant: those operations belong to the object owner and roles that inherit its privileges. Granting ALL on a table to a different role does not make that role an owner. EmDash does not run SET ROLE, so membership configured without inheritance is not sufficient.
Most installations can use the database’s existing schema, commonly public. This is the simplest option when the database is dedicated to EmDash. In the examples below, emdash_app is the login role in EmDash’s connection string; use an existing provider role or create a dedicated login. Grant it access with an administrative connection, substituting your database, schema, and role names:
GRANT CONNECT ON DATABASE app TO emdash_app;GRANT USAGE, CREATE ON SCHEMA public TO emdash_app;These grants let the role create new objects. They do not change the owner of existing tables; see Repair mixed PostgreSQL ownership.
EmDash uses PostgreSQL’s active current_schema(). It does not create a schema or set search_path, so verify the connection before deployment:
SELECT current_database(), session_user, current_user, current_schema(), current_setting('search_path');Optional: use a dedicated schema
Section titled “Optional: use a dedicated schema”Use a dedicated schema when EmDash shares a database with another application or when you want its objects isolated from public. This is optional and is easiest to configure before the first EmDash setup. A database dedicated to EmDash does not need a separate schema.
Assuming the canonical emdash_app role already exists, create and select its schema with an administrative connection:
GRANT CONNECT ON DATABASE app TO emdash_app;CREATE SCHEMA emdash AUTHORIZATION emdash_app;ALTER ROLE emdash_app IN DATABASE app SET search_path = emdash;This does not move an existing installation from public or repair mixed ownership. Existing sites should keep their current schema and follow Repair mixed PostgreSQL ownership instead.
Connection Pooling
Section titled “Connection Pooling”The adapter uses pg.Pool under the hood. Tune pool size based on your deployment:
database: postgres({ connectionString: process.env.DATABASE_URL, pool: { min: 2, max: 20 },});Hyperdrive
Section titled “Hyperdrive”Use the hyperdrive() adapter to run EmDash on Cloudflare Workers backed by an existing PostgreSQL — or Postgres-compatible (e.g. PlanetScale Postgres) — database. Hyperdrive pools and accelerates the connection over Cloudflare’s network; EmDash’s PostgreSQL dialect runs the queries.
import { hyperdrive, r2 } from "@emdash-cms/cloudflare";
export default defineConfig({ integrations: [ emdash({ database: hyperdrive({ binding: "HYPERDRIVE" }), storage: r2({ binding: "MEDIA" }), }), ],});Requirements
Section titled “Requirements”pg >= 8.16.3installed in your site (pnpm add pg)compatibility_flags: ["nodejs_compat"]compatibility_date >= "2024-09-23"
First prepare the PostgreSQL role. Then create the Hyperdrive configuration with that role’s connection string and add the binding to your Wrangler config:
wrangler hyperdrive create emdash-db \ --connection-string "postgres://user:password@host/db?sslmode=verify-full" \ --caching-disabled{ "hyperdrive": [ { "binding": "HYPERDRIVE", "id": "<your-hyperdrive-id>" } ]}[[hyperdrive]]binding = "HYPERDRIVE"id = "<your-hyperdrive-id>"Configuration
Section titled “Configuration”| Option | Type | Default | Description |
|---|---|---|---|
binding | string | "HYPERDRIVE" | Primary (caching-disabled) Hyperdrive binding name |
cachedBinding | string | — | Optional caching-enabled binding for anonymous reads (see below) |
preferUncachedAfterWriteMs | number | 60000* | After a content publish, prefer binding for this many ms on anonymous public reads (match Hyperdrive max_age) |
migrationConnectionStringEnv | string | CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_<BINDING> | Environment variable containing the direct PostgreSQL origin URL for emdash migrate |
max | number | 5 | Max size of the in-Worker connection pool to Hyperdrive |
*Default 60000 applies only when cachedBinding is set; ignored otherwise.
Serving anonymous reads from cache
Section titled “Serving anonymous reads from cache”By default you disable Hyperdrive caching entirely, because the admin and writes need read-after-write consistency. But anonymous public requests using GET or HEAD can tolerate a short staleness window. If that trade-off is acceptable, run two Hyperdrive configurations over the same database: one with caching off (the primary binding) and one with caching on (cachedBinding). EmDash routes those anonymous public requests through the cache-enabled binding and every other request through the uncached primary.
# Primary — caching OFF (used by admin, auth'd requests, writes, migrations)wrangler hyperdrive create emdash-db \ --connection-string "postgres://user:password@host/db?sslmode=verify-full" \ --caching-disabled
# Cached — SAME database role and connection string, caching ONwrangler hyperdrive create emdash-db-cached \ --connection-string "postgres://user:password@host/db?sslmode=verify-full"{ "hyperdrive": [ { "binding": "HYPERDRIVE", "id": "<caching-disabled-id>" }, { "binding": "HYPERDRIVE_CACHED", "id": "<caching-enabled-id>" } ]}database: hyperdrive({ binding: "HYPERDRIVE", cachedBinding: "HYPERDRIVE_CACHED" });This is the two-configuration pattern Cloudflare documents for caching. EmDash decides which binding to use per request:
- Anonymous reads of public-site paths (
GET/HEAD, no session, not under/_emdash) → cache-enabledcachedBinding, except for a short window after a content publish (default 60s; setpreferUncachedAfterWriteMsto your Hyperdrivemax_age) when EmDash prefers the uncachedbindingso a rebuild cannot reseed edge/object caches from still-stale Hyperdrive results. - Authenticated requests (editors, authors) → uncached
binding. - Mutation requests (
POST,PUT,PATCH,DELETE, including anonymous ones) → uncachedbinding. - Any request under
/_emdash(admin, setup, auth, internal APIs), even an anonymousGET→ uncachedbinding. - Runtime migrations and cold-start → always the primary
binding. - Deployment-managed migrations → connect directly to the PostgreSQL origin using
migrationConnectionStringEnv; they never use either Hyperdrive binding.
Optional: use a separate cached role
Section titled “Optional: use a separate cached role”Migrations, setup, authenticated requests, and explicit write requests always use the primary binding. A separate role for cachedBinding does not need schema ownership or CREATE, but it needs CONNECT, schema USAGE, and SELECT on every table used by the public site.
Anonymous public GET and HEAD requests can also record redirect hits and 404s. To preserve those features, the cached role additionally needs UPDATE on _emdash_redirects and SELECT, INSERT, UPDATE, and DELETE on _emdash_404_log. Plugins or application code that writes during a public GET or HEAD may require more. Use the same role for both bindings unless you have tested the site with a restricted cached role.
Add the cached role after EmDash has completed its initial migrations. The examples below use the optional emdash schema; substitute your active schema, such as public. Create the login and database settings with your provider’s administrative role:
CREATE ROLE emdash_cached LOGIN PASSWORD 'replace-with-a-secret';GRANT CONNECT ON DATABASE app TO emdash_cached;ALTER ROLE emdash_cached IN DATABASE app SET search_path = emdash;Then connect as emdash_app, the schema and table owner, to grant access to existing and future tables:
GRANT USAGE ON SCHEMA emdash TO emdash_cached;GRANT SELECT ON ALL TABLES IN SCHEMA emdash TO emdash_cached;GRANT UPDATE ON emdash._emdash_redirects TO emdash_cached;GRANT SELECT, INSERT, UPDATE, DELETE ON emdash._emdash_404_log TO emdash_cached;
ALTER DEFAULT PRIVILEGES IN SCHEMA emdash GRANT SELECT ON TABLES TO emdash_cached;Connect with both roles and verify they report the same current_database() and current_schema() before enabling cachedBinding. On a shared schema, GRANT SELECT ON ALL TABLES also exposes unrelated tables. Grant access to individual EmDash tables instead, and update those grants when collections or other schema objects are added.
Repair mixed PostgreSQL ownership
Section titled “Repair mixed PostgreSQL ownership”If a site has used multiple PostgreSQL users, first choose the canonical role that the primary EmDash connection will continue using. Take a backup and stop schema changes while repairing ownership.
Inspect every table in the active schema:
SELECT n.nspname AS schema_name, c.relname AS table_name, pg_get_userbyid(c.relowner) AS ownerFROM pg_class AS cJOIN pg_namespace AS n ON n.oid = c.relnamespaceWHERE n.nspname = current_schema() AND c.relkind IN ('r', 'p')ORDER BY c.relname;EmDash objects include _emdash_* and _plugin_* system tables, ec_* collection tables, and unprefixed tables such as content_taxonomies, media, options, revisions, and taxonomies. In a dedicated EmDash schema, every application table should have the canonical owner.
EmDash also creates PostgreSQL functions used by media-usage triggers. Inspect function ownership and retain each function’s argument signature for the repair command:
SELECT n.nspname AS schema_name, p.proname AS function_name, pg_get_function_identity_arguments(p.oid) AS arguments, pg_get_userbyid(p.proowner) AS ownerFROM pg_proc AS pJOIN pg_namespace AS n ON n.oid = p.pronamespaceWHERE n.nspname = current_schema()ORDER BY p.proname, arguments;Transfer each mismatched object with a superuser or provider role that can change its ownership, always using schema-qualified names:
ALTER TABLE emdash.content_taxonomies OWNER TO emdash_app;ALTER TABLE emdash.ec_posts OWNER TO emdash_app;ALTER FUNCTION emdash.emdash_media_usage_capture_work() OWNER TO emdash_app;Use the argument list returned by the inventory query inside each ALTER FUNCTION statement. Changing a table’s owner also covers its attached indexes, constraints, and triggers, but not their independent trigger functions. Repeat both inventory queries until every EmDash table and function reports the canonical owner, then connect as that role and verify current_schema() before starting the application.
For a non-superuser to transfer ownership, it must own or inherit ownership of the object, be able to SET ROLE to the new owner, and the new owner must have CREATE on the schema. Managed PostgreSQL providers may require their administrative role to perform the transfer.
SQLite
Section titled “SQLite”SQLite with better-sqlite3 is the simplest option for Node.js deployments.
import { sqlite } from "emdash/db";
export default defineConfig({ integrations: [ emdash({ database: sqlite({ url: "file:./data.db" }), }), ],});Configuration
Section titled “Configuration”| Option | Type | Description |
|---|---|---|
url | string | File path with file: prefix |
File Path
Section titled “File Path”The url must start with file::
// Relative pathdatabase: sqlite({ url: "file:./data/emdash.db" });
// Absolute pathdatabase: sqlite({ url: "file:/var/data/emdash.db" });
// From environment variabledatabase: sqlite({ url: `file:${process.env.DATABASE_PATH}` });Migrations
Section titled “Migrations”EmDash runs core migrations automatically by default for every supported dialect. Astro build and sync also emit a validated, secret-free .emdash/migrations.json, which emdash migrate can apply before deployment. SQLite, libSQL, PostgreSQL, D1, and the direct PostgreSQL origin behind Hyperdrive have deployment executors.
See Manage Core Database Migrations for target credentials, CI serialization, auto/check/manual runtime policy, and recovery from unknown records or ambiguous D1 writes.
For PostgreSQL, runtime migrations run through the configured connection; Hyperdrive runtime migrations always use its primary binding. Deployment-managed Hyperdrive migrations connect directly to the PostgreSQL origin. Core migrations may create tables, indexes, and functions, alter or drop columns and constraints, and update existing rows. A role that can connect and modify rows but does not own the existing EmDash objects is not sufficient. The setup wizard cannot repair missing database privileges because runtime migrations run before setup.
If the database is empty (no collections) and the setup wizard has not been completed, EmDash also applies a seed file on first boot. The seed is read from .emdash/seed.json, the path in package.json#emdash.seed, or seed/seed.json — whichever is found first — and inlined into the build at compile time. If none is present, a built-in default seed is used. Subsequent boots against an existing database leave its content alone.
Environment-Based Configuration
Section titled “Environment-Based Configuration”Use different databases per environment:
import { sqlite, libsql, postgres } from "emdash/db";import { d1 } from "@emdash-cms/cloudflare";
const database = import.meta.env.PROD ? d1({ binding: "DB" }) : sqlite({ url: "file:./data.db" });
export default defineConfig({ integrations: [emdash({ database })],});The choice can also key off an environment variable instead of the build mode:
const database = process.env.DATABASE_URL ? postgres({ connectionString: process.env.DATABASE_URL }) : sqlite({ url: "file:./data.db" });