Configuration Reference
EmDash is configured through two files: astro.config.mjs for the integration and src/live.config.ts for content collections.
Astro integration
Section titled “Astro integration”Configure EmDash as an Astro integration in astro.config.mjs:
import { defineConfig } from "astro/config";import emdash, { local, s3 } from "emdash/astro";import { sqlite, libsql } from "emdash/db";
export default defineConfig({ integrations: [ emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file", }), plugins: [], }), ],});Integration options
Section titled “Integration options”database
Section titled “database”Required. Database adapter configuration. Choose one adapter:
// SQLite (Node.js)database: sqlite({ url: "file:./data.db" });
// PostgreSQLdatabase: postgres({ connectionString: process.env.DATABASE_URL });
// libSQLdatabase: libsql({ url: process.env.LIBSQL_DATABASE_URL, authToken: process.env.LIBSQL_AUTH_TOKEN,});
// Cloudflare D1 (import from @emdash-cms/cloudflare)database: d1({ binding: "DB" });See Database Options for details.
storage
Section titled “storage”Required. Media storage adapter configuration. Choose one adapter:
// Local filesystem (development)storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file",});
// R2 binding (Cloudflare Workers)storage: r2({ binding: "MEDIA", publicUrl: "https://pub-xxxx.r2.dev", // optional});
// S3-compatible (any platform) — all fields from S3_* environment variablesstorage: s3()
// Or with explicit valuesstorage: s3({ endpoint: "https://s3.amazonaws.com", bucket: "my-bucket", accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, region: "us-east-1", // optional, default: "auto" publicUrl: "https://cdn.example.com", // optional});See Storage Options for details.
plugins
Section titled “plugins”Optional. Array of EmDash plugins. The following example registers one plugin:
import seoPlugin from "@emdash-cms/plugin-seo";
plugins: [seoPlugin()];Optional. Admin UI font configuration.
By default, EmDash loads Noto Sans via the Astro Font API. Fonts are downloaded from Google at build time and self-hosted, so there are no runtime CDN requests. The base font covers Latin, Cyrillic, Greek, Devanagari, and Vietnamese scripts.
To add support for additional writing systems, pass script names. The following example adds Arabic and Japanese:
emdash({ fonts: { scripts: ["arabic", "japanese"], },})The available scripts are arabic, armenian, bengali, chinese-simplified, chinese-traditional, chinese-hongkong, devanagari, ethiopic, farsi, georgian, gujarati, gurmukhi, hebrew, japanese, kannada, khmer, korean, lao, malayalam, myanmar, oriya, sinhala, tamil, telugu, thai, and tibetan.
Each script maps to the corresponding Noto Sans variant on Google Fonts (e.g. "arabic" loads Noto Sans Arabic). All font faces share a single font-family name and use unicode-range so the browser only downloads the files it needs for the characters on the page.
Set to false to disable font injection entirely and use system fonts:
emdash({ fonts: false,})The admin CSS uses the --font-emdash CSS variable. This is set automatically by the font configuration above.
Optional. An authentication adapter. EmDash’s built-in login is passkeys; setting auth replaces them with an external provider. The Cloudflare Access adapter, access(), is provided by @emdash-cms/cloudflare:
import { access } from "@emdash-cms/cloudflare";
emdash({ auth: access({ teamDomain: "myteam.cloudflareaccess.com", audience: "your-app-audience-tag", roleMapping: { Admins: 50, Editors: 40, }, }),});Options for access():
| Option | Type | Default | Description |
|---|---|---|---|
teamDomain | string | required | Your Cloudflare Access team domain |
audience | string | — | Application Audience (AUD) tag. On Workers, prefer audienceEnvVar. |
audienceEnvVar | string | "CF_ACCESS_AUDIENCE" | Environment variable to read the audience tag from at runtime |
autoProvision | boolean | true | Create an EmDash user on first login |
defaultRole | number | 30 | Role level for users not matched by roleMapping (see User roles) |
syncRoles | boolean | false | Re-apply roleMapping on every login instead of only at provisioning |
roleMapping | object | — | Map IdP group names to EmDash role levels; first match wins |
authProviders
Section titled “authProviders”Optional. An array of pluggable login providers (top-level, alongside auth). Each entry is the result of calling a provider factory, as shown below:
import { github } from "emdash/auth/providers/github";import { google } from "emdash/auth/providers/google";import { atproto } from "@emdash-cms/auth-atproto";
emdash({ authProviders: [github(), google(), atproto()],});Built-in providers:
github()— readsEMDASH_OAUTH_GITHUB_CLIENT_ID/EMDASH_OAUTH_GITHUB_CLIENT_SECRET(or unprefixed fallbacks).google()— readsEMDASH_OAUTH_GOOGLE_CLIENT_ID/EMDASH_OAUTH_GOOGLE_CLIENT_SECRET.atproto()— Atmosphere account login (Bluesky and the wider AT Protocol network). No env vars needed. Accepts{ allowedDIDs, allowedHandles, defaultRole }. See the Atmosphere login guide.
Third-party packages can register their own providers using the same AuthProviderDescriptor shape — see Login Providers.
siteUrl
Section titled “siteUrl”Optional. The public browser-facing origin for the site (scheme + host + optional port, no path).
Behind a TLS-terminating reverse proxy, Astro.url returns the internal address (http://localhost:4321) instead of the public one (https://cms.example.com). This breaks passkeys, CSRF origin matching, OAuth redirects, login redirects, MCP discovery, snapshot exports, sitemap, robots.txt, and JSON-LD structured data. Set siteUrl to fix all of these at once.
The integration validates this value at load time: it must be a valid URL with http: or https: protocol and is normalized to origin (path is stripped).
The following example sets the public origin:
emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file", }), siteUrl: "https://cms.example.com",});When siteUrl is not set in config, EmDash checks environment variables in order: EMDASH_SITE_URL, then SITE_URL. This is useful for container deployments where the public URL is set at runtime.
Multi-origin passkey verification
Section titled “Multi-origin passkey verification”siteUrl defines a single canonical origin. When the same EmDash deployment is reachable under several hostnames that share a registrable parent domain (e.g. https://example.com and https://preview.example.com), passkey verification rejects assertions whose origin doesn’t match siteUrl exactly — even though WebAuthn allows passkeys to be valid across subdomains under the same rpId.
Declare additional accepted origins via either allowedOrigins in astro.config.mjs or the EMDASH_ALLOWED_ORIGINS env var. The canonical siteUrl remains the source of rpId; entries listed here are accepted at verification time. The two sources are merged at runtime, so config can declare the stable origins (versioned, code-reviewed) while env adds environment-specific extras (e.g. ephemeral PR previews).
The following example declares one extra origin in config:
emdash({ siteUrl: "https://example.com", allowedOrigins: ["https://preview.example.com"],})The equivalent values can also come from environment variables:
EMDASH_SITE_URL=https://example.comEMDASH_ALLOWED_ORIGINS=https://preview.example.com,https://staging.example.comValidation
Section titled “Validation”EmDash validates these to prevent dead config the browser would never honor:
- Each entry must be a parseable
http:orhttps:URL with no trailing dot and no empty labels in the hostname. - When
allowedOriginsis non-empty,siteUrlmust be set (either source) and must not be an IP literal or have a trailing-dot hostname. - Each origin must be the same hostname as
siteUrlor a subdomain of it. (WebAuthn requiresrpIdto be a registrable suffix of every origin.)
When validation fails, you’ll see a source-attributed error like EmDash config error in EMDASH_ALLOWED_ORIGINS: "https://other-site.com" is not a subdomain of siteUrl "https://example.com". Allowed origins must be the same hostname as siteUrl or a subdomain of it.
Where the error surfaces depends on where the values are declared:
- At Astro startup, when both
config.allowedOriginsandconfig.siteUrlcome fromastro.config.mjs— typos in code fail the build. - At first passkey verification, when either value comes from
EMDASH_ALLOWED_ORIGINSorEMDASH_SITE_URL— env mismatches surface as 500s on the first verify attempt.
Reverse proxy setup
Section titled “Reverse proxy setup”Astro only reflects X-Forwarded-* when the public host is allowed. Configure security.allowedDomains for the hostname (and schemes) your users hit. In astro dev, add matching vite.server.allowedHosts so Vite accepts the proxy Host header.
Prefer fixing allowedDomains (and forwarded headers) first; use siteUrl when the reconstructed URL still diverges from the browser origin (typical when TLS is terminated in front and the upstream request stays http://).
With TLS in front, binding the dev server to loopback (astro dev --host 127.0.0.1) is often enough: the proxy connects locally while siteUrl matches the public HTTPS origin.
If your proxy writes a client-IP header, set trustedProxyHeaders so EmDash’s rate limits can use the real client IP instead of bucketing every request under a shared “unknown” key.
The following configuration sets allowedDomains, vite.server.allowedHosts, and siteUrl together for a reverse-proxy deployment:
import { defineConfig } from "astro/config";import emdash, { local } from "emdash/astro";import { sqlite } from "emdash/db";
export default defineConfig({ security: { allowedDomains: [ { hostname: "cms.example.com", protocol: "https" }, { hostname: "cms.example.com", protocol: "http" }, ], }, vite: { server: { allowedHosts: ["cms.example.com"], }, }, integrations: [ emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file", }), siteUrl: "https://cms.example.com", }), ],});trustedProxyHeaders
Section titled “trustedProxyHeaders”Optional. Headers to trust for client-IP resolution when running behind a reverse proxy you control. Used by auth rate limits (magic-link, signup, passkey, OAuth device flow) and the public comment endpoint.
On Cloudflare the cf object attached to the request is used automatically — you normally do not need to set this. On self-hosted deployments behind nginx, Caddy, Traefik, Fly, Railway, or similar, set this to the header your proxy writes so rate limits can bucket by real client IP instead of treating every request as “unknown”.
The following example trusts the x-real-ip header set by nginx, Caddy, or Traefik:
emdash({ database: sqlite({ url: "file:./data.db" }), trustedProxyHeaders: ["x-real-ip"],});Headers are tried in order. Values matching *-forwarded-for are parsed as comma-separated lists and the first entry is used. The following example prefers Fly.io’s header and falls back to x-forwarded-for:
emdash({ trustedProxyHeaders: ["fly-client-ip", "x-forwarded-for"],});When not set in config, EmDash reads the EMDASH_TRUSTED_PROXY_HEADERS env var (comma-separated). An explicit empty array in config overrides the env var.
maxUploadSize
Section titled “maxUploadSize”Optional. Maximum allowed media file upload size in bytes. Applies to both direct multipart uploads and signed-URL uploads. Defaults to 52_428_800 (50 MB). The following example raises the limit to 100 MB:
emdash({ database: sqlite({ url: "file:./data.db" }), storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file", }), maxUploadSize: 100 * 1024 * 1024, // 100 MB});| Value | Description |
|---|---|
number (bytes) | Must be a positive finite integer |
| omitted | Defaults to 50 MB |
Uploads that exceed the configured limit are rejected with a 413 Payload Too Large response on the direct upload path, or a 400 Validation Error on the signed-URL path.
Database adapters
Section titled “Database adapters”Import the adapters from emdash/db:
import { sqlite, libsql, postgres } from "emdash/db";sqlite(config)
Section titled “sqlite(config)”SQLite database using better-sqlite3. The following example connects to a local file:
| Option | Type | Description |
|---|---|---|
url | string | File path with file: prefix |
sqlite({ url: "file:./data.db" });libsql(config)
Section titled “libsql(config)”libSQL database. The following example connects to a remote libSQL database:
| Option | Type | Description |
|---|---|---|
url | string | Database URL |
authToken | string | Auth token (optional for local files) |
libsql({ url: process.env.LIBSQL_DATABASE_URL, authToken: process.env.LIBSQL_AUTH_TOKEN,});postgres(config)
Section titled “postgres(config)”PostgreSQL database with connection pooling.
| 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 size (default: 0) |
pool.max | number | Maximum pool size (default: 10) |
The following example connects with a connection string:
postgres({ connectionString: process.env.DATABASE_URL });d1(config)
Section titled “d1(config)”Cloudflare D1 database. Import from @emdash-cms/cloudflare.
| Option | Type | Default | Description |
|---|---|---|---|
binding | string | — | D1 binding name from wrangler.jsonc |
session | string | "disabled" | Read replication mode: "disabled", "auto", or "primary-first" |
bookmarkCookie | string | "__em_d1_bookmark" | Cookie name for session bookmarks |
The following example shows a basic binding and one with read replicas enabled:
// Basicd1({ binding: "DB" });
// With read replicasd1({ binding: "DB", session: "auto" });When session is "auto" or "primary-first", EmDash uses the D1 Sessions API to route read queries to nearby replicas. Authenticated users get bookmark-based read-your-writes consistency. See Database Options — Read Replicas for details.
Storage adapters
Section titled “Storage adapters”Import local and s3 from emdash/astro. The r2 adapter is imported from @emdash-cms/cloudflare:
import emdash, { local, s3 } from "emdash/astro";import { r2 } from "@emdash-cms/cloudflare";local(config)
Section titled “local(config)”Local filesystem storage. The following example serves uploads from a local directory:
| Option | Type | Description |
|---|---|---|
directory | string | Directory path |
baseUrl | string | Base URL for serving files |
local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file",});r2(config)
Section titled “r2(config)”Cloudflare R2 binding. The following example uses an R2 binding with a public URL:
| Option | Type | Description |
|---|---|---|
binding | string | R2 binding name |
publicUrl | string | Optional public URL |
r2({ binding: "MEDIA", publicUrl: "https://pub-xxxx.r2.dev",});s3(config?)
Section titled “s3(config?)”S3-compatible storage. All config fields are optional: any field omitted from
s3({...}) is resolved from the matching S3_* environment variable when the
Node process starts. Explicit values always take precedence.
Prerequisite: install @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner
in your project. EmDash core does not bundle the AWS SDK. See
Storage Options: S3-Compatible Storage
for details.
| Option | Type | Description |
|---|---|---|
endpoint | string | S3 endpoint URL (S3_ENDPOINT) |
bucket | string | Bucket name (S3_BUCKET) |
accessKeyId | string | Access key (S3_ACCESS_KEY_ID) |
secretAccessKey | string | Secret key (S3_SECRET_ACCESS_KEY) |
region | string | Region, default "auto" (S3_REGION) |
publicUrl | string | Optional CDN URL (S3_PUBLIC_URL) |
The following examples resolve all fields from the environment, mix config and environment, or pass every field explicitly:
// All fields from S3_* environment variables (Node container deployments)s3()
// Mix: CDN from config, rest from environments3({ publicUrl: "https://cdn.example.com" })
// All explicits3({ endpoint: "https://xxx.r2.cloudflarestorage.com", bucket: "media", accessKeyId: process.env.R2_ACCESS_KEY_ID, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY, publicUrl: "https://cdn.example.com",})Runtime environment variable resolution is a Node-only feature. On Cloudflare
Workers, secrets and variables are exposed through the env parameter of the
fetch handler, not through process.env, so S3_* environment variables are
not picked up. Workers deployments should either use the r2(config)
adapter or pass explicit values to s3({...}). See
Storage Options for details.
Live collections
Section titled “Live collections”Configure the EmDash loader in src/live.config.ts:
import { defineLiveCollection } from "astro:content";import { emdashLoader } from "emdash/runtime";
export const collections = { _emdash: defineLiveCollection({ loader: emdashLoader(), }),};Loader options
Section titled “Loader options”The emdashLoader() function takes no arguments:
emdashLoader();Environment variables
Section titled “Environment variables”EmDash respects these environment variables:
| Variable | Description |
|---|---|
EMDASH_SITE_URL | Public browser-facing origin (falls back to SITE_URL) |
EMDASH_ALLOWED_ORIGINS | Comma-separated list of additional origins accepted by passkey verification (multi-subdomain deployments). |
EMDASH_DATABASE_URL | Override database URL |
EMDASH_ENCRYPTION_KEY | Key for encrypting plugin secrets at rest. Operator-provided — never stored in the database. |
EMDASH_PREVIEW_SECRET | Optional override for preview HMAC secret. When unset, a stable per-site value is generated and stored in the database. |
EMDASH_IP_SALT | Optional override for the commenter-IP hash salt. When unset, a stable per-site value is generated and stored in the database. |
EMDASH_AUTH_SECRET | Legacy. Used as the IP-salt source if set; existing installs should keep this to preserve stable commenter-IP hashes across upgrade. |
EMDASH_URL | Remote EmDash URL for schema sync |
Generate an encryption key with the following command:
npx emdash secrets generatepackage.json configuration
Section titled “package.json configuration”Templates and sites can declare optional metadata under an emdash key in package.json:
{ "emdash": { "label": "My Blog Template", "seed": ".emdash/seed.json", "url": "https://my-site.pages.dev" }}| Option | Description |
|---|---|
label | Template name for display |
seed | Path to seed JSON file |
url | Remote URL for schema sync |
TypeScript configuration
Section titled “TypeScript configuration”EmDash generates types in .emdash/types.ts. Add a path alias to your tsconfig.json:
{ "compilerOptions": { "paths": { "@emdash-cms/types": ["./.emdash/types.ts"] } }}Generate types with the following command:
npx emdash types