Skip to content

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.

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.

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.

Terminal window
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:

Terminal window
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. Stop media writes while copying it, then verify representative files from the copy.

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

Terminal window
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 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:

Terminal window
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.

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. The following commands write the four files:

Terminal window
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:

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

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

Terminal window
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.

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:

Terminal window
sqlite3 emdash.db ".backup backup.db"

Check the copied database. The command must print ok:

Terminal window
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.

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:

Terminal window
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:

Terminal window
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:

Terminal window
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 for provider-managed recovery, large databases, and continuous archiving.

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:

Terminal window
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:

Terminal window
turso db shell emdash-production .dump > emdash.sql

Test the dump by creating an isolated database from it:

Terminal window
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.

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.

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.