---
title: Configuration Reference
description: Complete reference for EmDash configuration options.
---

import { Aside } from "@astrojs/starlight/components";

EmDash is configured through two files: `astro.config.mjs` for the integration and `src/live.config.ts` for content collections.

## Astro Integration

Configure EmDash as an Astro integration:

```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash, { local, r2, s3 } from "emdash/astro";
import { sqlite, libsql, d1 } 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

### `database`

**Required.** Database adapter configuration.

```js
// SQLite (Node.js)
database: sqlite({ url: "file:./data.db" });

// PostgreSQL
database: postgres({ connectionString: process.env.DATABASE_URL });

// libSQL
database: 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](/deployment/database/) for details.

### `storage`

**Required.** Media storage adapter configuration.

```js
// 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 variables
storage: s3()

// Or with explicit values
storage: 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](/deployment/storage/) for details.

### `plugins`

**Optional.** Array of EmDash plugins.

```js
import seoPlugin from "@emdash-cms/plugin-seo";

plugins: [seoPlugin()];
```

### `fonts`

**Optional.** Admin UI font configuration.

By default, EmDash loads [Noto Sans](https://fonts.google.com/noto/specimen/Noto+Sans) via the [Astro Font API](https://docs.astro.build/en/guides/fonts/). 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:

```js
emdash({
  fonts: {
    scripts: ["arabic", "japanese"],
  },
})
```

Available scripts: `arabic`, `armenian`, `bengali`, `chinese-simplified`, `chinese-traditional`, `chinese-hongkong`, `devanagari`, `ethiopic`, `georgian`, `gujarati`, `gurmukhi`, `hebrew`, `japanese`, `kannada`, `khmer`, `korean`, `lao`, `malayalam`, `myanmar`, `oriya`, `sinhala`, `tamil`, `telugu`, `thai`, `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:

```js
emdash({
  fonts: false,
})
```

The admin CSS uses the `--font-emdash` CSS variable. This is set automatically by the font configuration above.

### `auth`

**Optional.** Authentication configuration.

```js
import { github } from "emdash/auth/providers/github";
import { google } from "emdash/auth/providers/google";
import { atproto } from "@emdash-cms/auth-atproto";

emdash({
	auth: {
		// Self-signup configuration
		selfSignup: {
			domains: ["example.com"],
			defaultRole: 20, // Contributor
		},

		// Session configuration
		session: {
			maxAge: 30 * 24 * 60 * 60, // 30 days
			sliding: true, // Reset expiry on activity
		},

		// OR use Cloudflare Access (exclusive mode)
		cloudflareAccess: {
			teamDomain: "myteam.cloudflareaccess.com",
			audience: "your-app-audience-tag",
			autoProvision: true,
			defaultRole: 30,
			syncRoles: false,
			roleMapping: {
				Admins: 50,
				Editors: 40,
			},
		},
	},
	// Pluggable login providers (top-level, not nested under `auth`)
	authProviders: [github(), google(), atproto()],
});
```

#### `auth.selfSignup`

Allow users to self-register if their email domain is allowed.

| Option        | Type       | Default | Description               |
| ------------- | ---------- | ------- | ------------------------- |
| `domains`     | `string[]` | `[]`    | Allowed email domains     |
| `defaultRole` | `number`   | `20`    | Role for self-signups     |

```js
selfSignup: {
  domains: ["example.com", "acme.org"],
  defaultRole: 20, // Contributor
}
```

### `authProviders`

**Optional.** An array of pluggable login providers (top-level, alongside `auth`). Each entry is the result of calling a provider factory:

```js
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()` — reads `EMDASH_OAUTH_GITHUB_CLIENT_ID` / `EMDASH_OAUTH_GITHUB_CLIENT_SECRET` (or unprefixed fallbacks).
- `google()` — reads `EMDASH_OAUTH_GOOGLE_CLIENT_ID` / `EMDASH_OAUTH_GOOGLE_CLIENT_SECRET`.
- `atproto()` — Atmosphere/AT Protocol login. No env vars needed. Accepts `{ allowedDIDs, allowedHandles, defaultRole }`. See the [Atmosphere login guide](/guides/atmosphere-auth/).

Third-party packages can register their own providers using the same `AuthProviderDescriptor` shape — see [Login Providers](/guides/authentication/#login-providers).

#### `auth.session`

Session configuration.

| Option    | Type      | Default           | Description                    |
| --------- | --------- | ----------------- | ------------------------------ |
| `maxAge`  | `number`  | `2592000` (30d)   | Session lifetime in seconds    |
| `sliding` | `boolean` | `true`            | Reset expiry on activity       |

#### `auth.cloudflareAccess`

Use Cloudflare Access as the authentication provider instead of passkeys.

| Option          | Type      | Default  | Description                    |
| --------------- | --------- | -------- | ------------------------------ |
| `teamDomain`    | `string`  | required | Your Access team domain        |
| `audience`      | `string`  | required | Application Audience (AUD) tag |
| `autoProvision` | `boolean` | `true`   | Create users on first login    |
| `defaultRole`   | `number`  | `30`     | Default role for new users     |
| `syncRoles`     | `boolean` | `false`  | Update role on each login      |
| `roleMapping`   | `object`  | —        | Map IdP groups to roles        |

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

### `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).

```js
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.

<Aside type="tip">
	`siteUrl` replaces `passkeyPublicOrigin` (removed). If you were using `passkeyPublicOrigin`, rename it to `siteUrl` -- it now covers passkeys and all other origin-dependent features.
</Aside>

#### 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).

```js title="astro.config.mjs"
emdash({
	siteUrl: "https://example.com",
	allowedOrigins: ["https://preview.example.com"],
})
```

```bash title=".env / wrangler.jsonc / Docker env"
EMDASH_SITE_URL=https://example.com
EMDASH_ALLOWED_ORIGINS=https://preview.example.com,https://staging.example.com
```

##### Validation

EmDash validates these to prevent dead config the browser would never honor:

- Each entry must be a parseable `http:` or `https:` URL with no trailing dot and no empty labels in the hostname.
- When `allowedOrigins` is non-empty, `siteUrl` must 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 `siteUrl` or a subdomain of it. (WebAuthn requires `rpId` to 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.allowedOrigins` and `config.siteUrl` come from `astro.config.mjs` -- typos in code fail the build.
- **At first passkey verification**, when either value comes from `EMDASH_ALLOWED_ORIGINS` or `EMDASH_SITE_URL` -- env mismatches surface as 500s on the first verify attempt.

#### Reverse proxy setup

Astro only reflects **`X-Forwarded-*`** when the public host is allowed. Configure [**`security.allowedDomains`**](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains) 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`**](#trustedproxyheaders) so EmDash's rate limits can use the real client IP instead of bucketing every request under a shared "unknown" key.

<Aside type="caution">
	Your reverse proxy should forward a **port-aware** `Host` / `X-Forwarded-Host` when you use non-default ports. If the proxy strips the port, **`rpId`** and Astro’s rebuilt URL can be wrong.
</Aside>

```js title="astro.config.mjs (excerpt)"
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`

**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".

```js
emdash({
	database: sqlite({ url: "file:./data.db" }),
	// nginx / Caddy / Traefik
	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.

```js
emdash({
	// Fly.io, falling back to generic X-Forwarded-For
	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.

<Aside type="caution">
	**Only set this when you control the reverse proxy.** Untrusted clients can set any header
	they like; trusting forwarded-IP headers from an open network is an IP-spoofing vulnerability
	that defeats rate limiting. If EmDash is exposed directly to the public internet with no
	proxy in front, leave this unset — rate limits will fall back to a shared "unknown" bucket
	(stricter defaults) rather than trust a spoofable header.
</Aside>

### `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).

```js
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

Import from `emdash/db`:

```js
import { sqlite, libsql, postgres, d1 } from "emdash/db";
```

### `sqlite(config)`

SQLite database using better-sqlite3.

| Option | Type     | Description                   |
| ------ | -------- | ----------------------------- |
| `url`  | `string` | File path with `file:` prefix |

```js
sqlite({ url: "file:./data.db" });
```

### `libsql(config)`

libSQL database.

| Option      | Type     | Description                           |
| ----------- | -------- | ------------------------------------- |
| `url`       | `string` | Database URL                          |
| `authToken` | `string` | Auth token (optional for local files) |

```js
libsql({
	url: process.env.LIBSQL_DATABASE_URL,
	authToken: process.env.LIBSQL_AUTH_TOKEN,
});
```

### `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)      |

```js
postgres({ connectionString: process.env.DATABASE_URL });
```

### `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                   |

```js
// Basic
d1({ binding: "DB" });

// With read replicas
d1({ 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](/deployment/database/#read-replicas) for details.

<Aside type="caution">
	D1 requires migrations via Wrangler CLI. DDL statements are not allowed at runtime.
</Aside>

## Storage Adapters

Import from `emdash/astro`:

```js
import emdash, { local, r2, s3 } from "emdash/astro";
```

### `local(config)`

Local filesystem storage.

| Option      | Type     | Description                |
| ----------- | -------- | -------------------------- |
| `directory` | `string` | Directory path             |
| `baseUrl`   | `string` | Base URL for serving files |

```js
local({
	directory: "./uploads",
	baseUrl: "/_emdash/api/media/file",
});
```

### `r2(config)`

Cloudflare R2 binding.

| Option      | Type     | Description         |
| ----------- | -------- | ------------------- |
| `binding`   | `string` | R2 binding name     |
| `publicUrl` | `string` | Optional public URL |

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

### `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](/deployment/storage/#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`)  |

```js
// All fields from S3_* environment variables (Node container deployments)
s3()

// Mix: CDN from config, rest from environment
s3({ publicUrl: "https://cdn.example.com" })

// All explicit (unchanged from before)
s3({
	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)`](#r2config)
adapter or pass explicit values to `s3({...})`. See
[Storage Options](/deployment/storage/#s3-compatible-storage) for details.

## Live Collections

Configure the EmDash loader in `src/live.config.ts`:

```ts title="src/live.config.ts"
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";

export const collections = {
	_emdash: defineLiveCollection({
		loader: emdashLoader(),
	}),
};
```

### Loader Options

The `emdashLoader()` function accepts optional configuration:

```ts
emdashLoader({
	// Currently no options - reserved for future use
});
```

## 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:

```bash
npx emdash secrets generate
```

## package.json Configuration

Optional configuration in `package.json`:

```json title="package.json"
{
	"emdash": {
		"label": "My Blog Template",
		"description": "A clean, minimal blog template",
		"seed": ".emdash/seed.json",
		"url": "https://my-site.pages.dev",
		"preview": "https://emdash-blog.pages.dev"
	}
}
```

| Option        | Description                        |
| ------------- | ---------------------------------- |
| `label`       | Template name for display          |
| `description` | Template description               |
| `seed`        | Path to seed JSON file             |
| `url`         | Remote URL for schema sync         |
| `preview`     | Demo site URL for template preview |

## TypeScript Configuration

EmDash generates types in `.emdash/types.ts`. Add to your `tsconfig.json`:

```json title="tsconfig.json"
{
	"compilerOptions": {
		"paths": {
			"@emdash-cms/types": ["./.emdash/types.ts"]
		}
	}
}
```

Generate types with:

```bash
npx emdash types
```
