# Backups and recovery

> Create and verify restorable database, media, and key backups for every supported EmDash database.

Source: https://emdashcmseverything.com/official-docs/guides/backups/

A recovery point must restore the complete database. EmDash's JSON backups are useful for inspection and custom tooling, but EmDash cannot restore them. Use a raw database backup or point-in-time recovery before a database migration or destructive operation.

Back up media binaries separately. If the site stores encrypted plugin settings, keep the complete `EMDASH_ENCRYPTION_KEY` rotation list in a separate secret-manager backup. Database backups and exports do not contain those files or keys.

## Choose a recovery method

Use the method for the site's configured [database adapter](https://emdashcmseverything.com/official-docs/deployment/database/):

| Database | Recovery point |
| --- | --- |
| Cloudflare D1 | Record a Time Travel bookmark. Create an offsite SQL dump when the required retention exceeds Time Travel or when a restore rehearsal is required. |
| SQLite or local libSQL | Use SQLite's online backup command, then run an integrity check against the copy. |
| PostgreSQL | Use the provider's point-in-time recovery or create a custom-format archive with `pg_dump`. |
| Hyperdrive | Back up its PostgreSQL origin through a direct origin connection. A Hyperdrive binding is not a backup connection. |
| Turso-hosted libSQL | Use Turso point-in-time recovery or create and rehearse a SQL dump. |
| Another remote libSQL provider | Use the provider's full-database export or point-in-time recovery procedure and test it against an isolated database. |

Keep the recovery point with the matching application artifact, media backup, and key-list version. Before a planned migration, record the exact database and environment, stop application writes when the provider's procedure requires it, and confirm that the recovery point remains available for the intended rollback window.

## Back up and restore media objects

R2 and S3-compatible buckets need an object-level backup in addition to the database. The following AWS CLI command copies every object, including EmDash JSON archives under `backups/`, to a local backup directory. For AWS S3, omit `--endpoint-url`.

```bash
aws s3 sync s3://emdash-media ./emdash-media-backup \
  --endpoint-url https://<account-id>.r2.cloudflarestorage.com
```

Replace `<account-id>` with the Cloudflare account ID. Use read-only bucket credentials for routine backup jobs. Store the backup outside the production account or failure domain, and record the database recovery point created at the same time.

Restore into an empty recovery bucket rather than overwriting production while it is serving requests:

```bash
aws s3 sync ./emdash-media-backup s3://emdash-media-recovery \
  --endpoint-url https://<account-id>.r2.cloudflarestorage.com
```

Give the restore job write access only to the recovery bucket. Point a non-production deployment at the restored database and bucket, open several known media URLs, and upload and delete a disposable file. Change the production binding or bucket configuration only after the database and media pass their checks together.

For local storage, copy the directory configured by the [`local()` storage adapter](https://emdashcmseverything.com/official-docs/deployment/storage/#local-filesystem). Stop media writes while copying it, then verify representative files from the copy.

## Recover a D1 database with Time Travel

Before a migration, ask Time Travel for the database's current bookmark and record the complete output with the deployment:

```bash
npx wrangler d1 time-travel info my-database
```

Time Travel recovery points expire. Cloudflare currently retains them for 7 days on the Workers Free plan and 30 days on the Workers Paid plan. Create an [offsite D1 dump](#create-an-offsite-d1-dump) when the rollback window must last longer.

If recovery is required, stop writes to the site and restore the exact bookmark recorded before the migration:

```bash
npx wrangler d1 time-travel restore my-database \
  --bookmark="<pre-migration-bookmark>"
```

Replace `<pre-migration-bookmark>` with the recorded bookmark. The command overwrites the database in place and cancels in-flight queries. Save the previous bookmark printed by the restore command so the restore itself can be undone.

Time Travel restores content, users, settings, plugin data, and migration records. It does not restore R2 media objects or `EMDASH_ENCRYPTION_KEY`. Restore the matching application artifact, media, and keys before reopening traffic. Verify sign-in, content reads, the schema, an encrypted plugin setting, and a write.

See the [D1 Time Travel documentation](https://developers.cloudflare.com/d1/reference/time-travel/) for current retention, requirements, and restore behavior.

## Create an offsite D1 dump

For a SQL dump of the raw database (including users and auth tables), use Wrangler. A plain `wrangler d1 export my-database --remote` fails with `D1 Export error: cannot export databases with Virtual Tables (fts5)` once any collection has search enabled, because EmDash keeps its search index in FTS5 virtual tables. Export the other tables by name and leave the search index out; the restored site rebuilds it.

The dump is split into four files so that a restore succeeds:

- `backup-schema.sql` creates the tables.
- `backup-folders.sql` holds the media folders. Large imports run in parts, so a media item would otherwise be inserted before the folder it belongs to and fail the foreign key check.
- `backup-data.sql` holds every other row.
- `backup-indexes.sql` holds the indexes and triggers, which an export by table name leaves out. They are created last so that the triggers do not fire while rows are imported.

These commands require [`jq`](https://jqlang.org/). The following commands write the four files:

```bash
npx wrangler d1 execute my-database --remote --json \
  --command "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB '_emdash_fts_*' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*' AND name != 'media_folders'" \
  | jq -r '.[0].results[] | "--table=" + .name' > backup-tables.txt

npx wrangler d1 export my-database --remote --no-data --output=backup-schema.sql \
  --table=media_folders $(cat backup-tables.txt)
npx wrangler d1 export my-database --remote --no-schema --output=backup-folders.sql \
  --table=media_folders
npx wrangler d1 export my-database --remote --no-schema --output=backup-data.sql \
  $(cat backup-tables.txt)

npx wrangler d1 execute my-database --remote --json \
  --command "SELECT sql FROM sqlite_master WHERE type IN ('index', 'trigger') AND sql IS NOT NULL AND name NOT GLOB '_emdash_fts_*' AND tbl_name NOT GLOB '_emdash_fts_*' AND tbl_name NOT GLOB '_cf_*'" \
  | jq -r '.[0].results[] | .sql + ";"' > backup-indexes.sql
```

D1 rejects SQL statements of 100,000 bytes or more, and the export writes each row as one `INSERT` on its own line. A row with a long value, such as a long entry or one of its revisions, can exceed that limit. Check the files right after you create them. The following command prints the file and line of every statement that is too long for D1, and prints nothing when the files can be imported:

```bash
LC_ALL=C awk 'length($0) >= 100000 { print FILENAME ":" FNR }' backup-*.sql
```

> **Caution**
>
> If the command prints any lines, importing that file into D1 fails with an error containing `SQLITE_TOOBIG`, and no rows from the file are written. Keep the files anyway: they are a complete offsite copy that `sqlite3` can import, but they cannot be imported into D1 as they are. For D1 recovery, rely on [Time Travel](#recover-a-d1-database-with-time-travel), which reaches back 30 days on the Workers Paid plan and 7 days on the Workers Free plan.

See the [D1 import and export documentation](https://developers.cloudflare.com/d1/best-practices/import-export-data/) for Wrangler's export options and limits.

Keep the SQL files with the matching application version, the media backup created at the same time, and a separately stored copy of the `EMDASH_ENCRYPTION_KEY` rotation list. Test recovery by importing the files into a newly provisioned empty D1 database, restoring the runtime keys, updating a non-production binding to that database, and verifying the site.

> **Danger**
>
> Do not execute the dump files against the existing production database. They contain schema and row creation statements that can conflict with existing objects or leave a mixed state. For recovery, create an empty D1 database, import the files there, verify it, then change the production binding in a controlled deployment.

If the dump was not checked when it was created, run the length check above first. Then import the files into the empty recovery database in this order:

```bash
npx wrangler d1 execute my-recovery-database --remote --file=backup-schema.sql
npx wrangler d1 execute my-recovery-database --remote --file=backup-folders.sql
npx wrangler d1 execute my-recovery-database --remote --file=backup-data.sql
npx wrangler d1 execute my-recovery-database --remote --file=backup-indexes.sql
```

The restored database has no search index. EmDash rebuilds it the first time the search API (`/_emdash/api/search`) is called on the restored site, for example by the admin command palette or a search box that uses the API. Until then, pages that call `search()` directly return no results. To rebuild right away, request `/_emdash/api/search?q=a` once after the site is running.

## Back up SQLite and local libSQL

For an offline backup, stop every process that writes the database and copy the database file together with any `-wal` and `-shm` files beside it. The `-wal` file can contain committed changes that are not yet in the main file.

For a consistent online backup, use SQLite's backup command against the path configured by the database adapter:

```bash
sqlite3 emdash.db ".backup backup.db"
```

Check the copied database. The command must print `ok`:

```bash
sqlite3 backup.db "PRAGMA integrity_check"
```

Open the copy with the matching application version in a non-production environment and verify sign-in, public content, an encrypted plugin setting, an edit, and a media read.

To recover, stop every process that writes the database, retain a copy of the damaged database and its `-wal` and `-shm` files, replace them with the verified backup, and restore the matching application artifact, media, and keys before reopening traffic.

## Back up PostgreSQL and Hyperdrive

Use the PostgreSQL provider's point-in-time recovery or snapshot feature when it offers an isolated restore that can be tested before production is changed. Record its exact timestamp or snapshot ID and retention period.

For a portable backup, connect directly to the PostgreSQL origin and create a custom-format archive:

```bash
pg_dump --format=custom --file=emdash.dump "$DATABASE_URL"
```

`pg_dump` creates a transactionally consistent archive while the database remains in use. Inspect the archive table of contents before relying on it:

```bash
pg_restore --list emdash.dump > emdash.dump.list
```

Provision an empty recovery database. Connect as the canonical EmDash application role and restore without carrying source ownership into the recovery database:

```bash
pg_restore --exit-on-error --no-owner \
  --dbname="$RECOVERY_DATABASE_URL" emdash.dump
```

The role in `RECOVERY_DATABASE_URL` becomes the owner of restored EmDash objects. Use the same stable role that the recovered application will use. Point a non-production deployment at the recovery database and verify sign-in, content reads, the schema, an encrypted plugin setting, and a write.

For Hyperdrive, `DATABASE_URL` must be the direct PostgreSQL origin connection named by `migrationConnectionStringEnv`; the Worker binding cannot be passed to PostgreSQL backup tools. Follow the origin provider's recovery procedure and retain the Hyperdrive configuration with the application artifact.

See the [PostgreSQL backup documentation](https://www.postgresql.org/docs/current/backup.html) for provider-managed recovery, large databases, and continuous archiving.

## Back up Turso-hosted libSQL

Turso point-in-time recovery creates a separate database from an earlier timestamp. Record an RFC 3339 timestamp immediately before the migration. If recovery is required, create an isolated database from that point:

```bash
turso db create emdash-recovery \
  --from-db emdash-production \
  --timestamp <pre-migration-rfc3339-timestamp>
```

Replace `<pre-migration-rfc3339-timestamp>` with the recorded timestamp. Create a token for the recovered database, point a non-production deployment at it, and verify the site before changing the production connection. Check [Turso point-in-time recovery](https://docs.turso.tech/features/point-in-time-recovery) for the retention provided by the site's plan.

For an offsite copy, dump the complete database:

```bash
turso db shell emdash-production .dump > emdash.sql
```

Test the dump by creating an isolated database from it:

```bash
turso db create emdash-recovery --from-dump ./emdash.sql
```

For another remote libSQL provider, follow its full-database export or point-in-time recovery procedure. Do not apply a remote migration until that procedure has produced a recovery point and an isolated restore has been tested.

## Use JSON backups for inspection

A JSON backup includes:

- All content entries, including drafts, scheduled posts, and trashed items
- Collection and field definitions that make up the content model
- Taxonomy definitions, terms, and the terms assigned to each entry
- Menus and menu items, sections, widget areas and widgets, SEO records, revision history, media metadata, and database migration history
- Site settings such as the title, tagline, URL, locale, logo, display preferences, social profiles, and SEO defaults from the `site:` setting group and the `emdash:site_title`, `emdash:site_tagline`, and `emdash:locale` settings

It omits:

- User accounts, sessions, passkeys, OAuth data, API tokens, and other authentication data
- Plugin storage and plugin settings, including plugin secrets
- Comments and reactions, redirects and 404 logs, bylines, content relations and references, audit logs, rate limits, and scheduled-task state
- Media folders, records of where media is used, incomplete uploads, and the media files themselves
- Other site options, including `emdash:site_url`, the preview signing secret, and the backup schedule

Under **Settings → Backups**, **Download backup** generates a fresh JSON file. **Back up now** stores one under the `backups/` prefix when storage is configured. These files use the same versioned snapshot format as EmDash previews.

> **Caution**
>
> EmDash has no admin action, API endpoint, or CLI command that restores this JSON format. Do not use
> it as a recovery point before a migration or destructive operation.

To schedule JSON backups, enable **Daily automatic backups** under **Settings → Backups** and choose a retention of 1–30 files. They run during scheduled maintenance. On Cloudflare, the Worker needs a cron trigger; on Node, the built-in scheduler runs the task.

If the bucket has a public R2 domain or content delivery network origin, restrict that origin to media objects or serve media through EmDash. The EmDash media route refuses to serve `backups/`, but a public bucket origin can expose every object by URL.

To copy content, settings, and media into a new EmDash site, use a [site package](https://emdashcmseverything.com/official-docs/guides/site-transfer/). A site package does not restore users, API tokens, plugin data, or secrets, so it does not replace a database recovery point.
