Backups and recovery
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
Section titled “Choose a recovery method”Use the method for the site’s configured database adapter:
| 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
Section titled “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.
aws s3 sync s3://emdash-media ./emdash-media-backup \ --endpoint-url https://<account-id>.r2.cloudflarestorage.comReplace <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:
aws s3 sync ./emdash-media-backup s3://emdash-media-recovery \ --endpoint-url https://<account-id>.r2.cloudflarestorage.comGive 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. Stop media writes while copying it, then verify representative files from the copy.
Recover a D1 database with Time Travel
Section titled “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:
npx wrangler d1 time-travel info my-databaseTime 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 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:
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 for current retention, requirements, and restore behavior.
Create an offsite D1 dump
Section titled “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.sqlcreates the tables.backup-folders.sqlholds 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.sqlholds every other row.backup-indexes.sqlholds 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. The following commands write the four files:
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_foldersnpx 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.sqlD1 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:
LC_ALL=C awk 'length($0) >= 100000 { print FILENAME ":" FNR }' backup-*.sqlSee the D1 import and export documentation 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.
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:
npx wrangler d1 execute my-recovery-database --remote --file=backup-schema.sqlnpx wrangler d1 execute my-recovery-database --remote --file=backup-folders.sqlnpx wrangler d1 execute my-recovery-database --remote --file=backup-data.sqlnpx wrangler d1 execute my-recovery-database --remote --file=backup-indexes.sqlThe 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
Section titled “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:
sqlite3 emdash.db ".backup backup.db"Check the copied database. The command must print ok:
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
Section titled “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:
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:
pg_restore --list emdash.dump > emdash.dump.listProvision an empty recovery database. Connect as the canonical EmDash application role and restore without carrying source ownership into the recovery database:
pg_restore --exit-on-error --no-owner \ --dbname="$RECOVERY_DATABASE_URL" emdash.dumpThe 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 for provider-managed recovery, large databases, and continuous archiving.
Back up Turso-hosted libSQL
Section titled “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:
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 for the retention provided by the site’s plan.
For an offsite copy, dump the complete database:
turso db shell emdash-production .dump > emdash.sqlTest the dump by creating an isolated database from it:
turso db create emdash-recovery --from-dump ./emdash.sqlFor 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
Section titled “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 theemdash:site_title,emdash:site_tagline, andemdash:localesettings
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.
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. A site package does not restore users, API tokens, plugin data, or secrets, so it does not replace a database recovery point.