guides
Backing up and restoring a Cloudflare email stack (D1 + R2)
May 5, 2026

The day you own your email data is the day you also own its backups. There is no support ticket that un-deletes your inbox, and a botched migration on a Friday is now entirely your problem. This is the runbook for not losing everything.
Moving off Gmail and Resend and Mailchimp onto your own Cloudflare account buys you control, residency, and an end to per-contact pricing. It also hands you a responsibility those SaaS vendors quietly carried: if the data is gone, you are the one who has to bring it back. There is no vendor with a hot standby of your mailbox.
The good news is that a Cloudflare-native email stack is unusually easy to back up correctly, because its state lives in exactly two well-defined places and both have decent tooling. The trap is that those two places have very different backup models, and a backup that captures one but not the other will fail at exactly the moment you need it. This guide covers what to back up, the two D1 backup paths and the one R2 path, how to restore them as a matched pair, and how to verify the restore actually holds together.
What exactly do you need to back up in a Cloudflare email stack?
Two stores, and the link between them. A self-hosted email stack on Cloudflare splits state across D1 (SQLite) for searchable metadata and R2 (object storage) for raw bytes. Lose either and the other is crippled.
Here is the split, concretely, for a Tinbox-style deployment:
| Store | What it holds | Backup model |
|---|---|---|
D1 (tinbox database) | Message rows, threads, attachment rows, subscriber/audience lists, send ledger, FTS5 search index, auth tables | Time Travel (30 days, in-account) + wrangler d1 export (off-platform) |
R2 (tinbox-mail bucket) | Raw .eml message bodies, attachment blobs | Manual replication only (no Time Travel, no built-in versioning) |
| KV | Live-mutable flags, suppression cache, draft coordination | Mostly rebuildable; back up if you treat suppression as authoritative |
The critical detail: D1 and R2 are joined by a key. When the ingest path writes a message, it streams the raw .eml and each attachment to R2, then inserts one message row plus N attachment rows into D1 holding only the R2 keys, never the bytes. The keys are deterministic:
inboxes/<inboxId>/messages/<messageRowId>/raw.eml
inboxes/<inboxId>/messages/<messageRowId>/attachments/<index>-<filename>
So an attachment row in D1 has an r2_key column, and that string is the entire contract between the two stores. Back up D1 and R2 far apart in time, or restore them out of sync, and you get dangling keys: rows that point at objects that no longer exist, or objects that no row references. Everything below is organized around keeping that contract intact.
How do you back up D1: Time Travel vs explicit exports
Use both, for different jobs. Time Travel is your "undo the last bad thing" tool; explicit exports are your "the account is gone" tool. They are not interchangeable.
Time Travel: in-account, 30 days, zero setup
D1 continuously records a write-ahead log and lets you restore the database to any point in the last 30 days without any prior configuration. You do nothing to enable it. To find a bookmark for a past moment and inspect it:
# What's the current restore window?
wrangler d1 time-travel info tinbox --remote
# Restore the database to a specific moment (e.g. just before a bad migration)
wrangler d1 time-travel restore tinbox --remote --timestamp=2026-06-18T09:00:00Z
Time Travel is excellent for the most common disaster: someone ran a destructive migration, a DELETE without a WHERE, or a buggy bulk operation against the subscriber list. You can rewind minutes later. But be clear about its limits:
- It lives inside your Cloudflare account. If the account is suspended, compromised, or closed, Time Travel goes with it.
- It is bounded to 30 days. A corruption you notice in month two is past the window.
- It only touches D1. It does nothing for R2, so a Time Travel restore on its own re-introduces the dangling-key problem the moment your R2 state has moved on.
Treat Time Travel as a seatbelt, not as a backup strategy.
Explicit exports: your real off-platform safety net
The export is the artifact you actually own. It is a portable SQL dump you can store anywhere, replay into a fresh database, and keep for as long as you like:
# Full dump of schema + data to a local file
wrangler d1 export tinbox --remote --output=d1-backup-2026-06-18.sql
# Schema only (handy for diffing migrations)
wrangler d1 export tinbox --remote --no-data --output=d1-schema.sql
Run this on a schedule (a daily cron job on a small VM, a GitHub Action with a Cloudflare API token, or a scheduled Worker that calls the export API) and push the result somewhere off Cloudflare. For a moderate inbox the dump is small and compresses well; for a large archive, exclude the FTS5 shadow tables if your tooling chokes on them, because they are rebuildable from the source rows.
One honest caveat: a wrangler d1 export is not perfectly transactionally consistent against a live, high-write database, because it reads the database while writes continue. For an email stack the practical risk is low (a handful of in-flight messages at the export boundary), and the next day's export catches them. If you need a strictly consistent snapshot, take a Time Travel bookmark first and export from a copy restored to that bookmark.
How do you back up R2 when there is no Time Travel?
You replicate it yourself, on a schedule, ideally off the account. R2 has no Time Travel and no built-in object versioning equivalent to S3's, so an overwritten or deleted object is gone unless you copied it somewhere first. This is the part people forget, and it is the part that holds your actual email content.
The cleanest tool is rclone, which speaks R2's S3-compatible API. Configure two remotes (your live bucket and a backup target) and sync:
# rclone.conf has two remotes: `r2` (live) and `backup` (a second account or B2/S3)
rclone sync r2:tinbox-mail backup:tinbox-mail-backup \
--transfers=32 \
--checksum \
--log-level INFO
sync makes the destination match the source, including deletions, which is what you want for a true mirror. If you would rather keep deleted objects (so a deletion in the live bucket does not propagate to the backup), use copy instead of sync, or sync into a dated prefix:
# Append-only style: never deletes from the backup
rclone copy r2:tinbox-mail backup:tinbox-mail-archive/2026-06-18/
Two things make R2 backups pleasant compared to other object stores:
- No egress fees. Copying the entire bucket out costs you Class B operation charges, not bandwidth, so a full mirror is cheap and a frequent incremental sync is cheaper.
- Deterministic keys. Because keys encode the inbox and message IDs, you can reason about what should be there, and an integrity check is a straightforward set comparison rather than a guess.
Where should the backup target live? Not in the same Cloudflare account as the live stack. The whole premise of owning your email is resilience against single points of failure, and "your Cloudflare account" is a single point of failure. Push the R2 mirror to a second Cloudflare account with separate credentials, or to Backblaze B2, or to S3, or even pull a periodic copy down to a NAS. Pick something whose blast radius does not overlap with production.
If you prefer not to run a box, a scheduled Worker can do incremental replication: on a cron trigger, list objects modified since the last run and put them into the backup bucket via its S3 endpoint. That keeps everything inside Cloudflare's edge but still gets the bytes off the primary bucket. The tradeoff is that a Worker-based copy to a second Cloudflare account survives bucket-level mistakes but not account-level loss, so for true disaster recovery, at least your periodic full mirror should leave Cloudflare entirely.
How do you restore the whole stack, and in what order?
D1 first, then R2, then verify. Order matters because the integrity check you run at the end compares D1's r2_key references against the objects actually present in R2, and you want both sides settled before you check.
Step 1: restore D1
For an in-window mistake, prefer Time Travel (above). For a full rebuild from an export, create a fresh database and replay the dump:
# Create the target database
wrangler d1 create tinbox-restored
# Replay the SQL dump into it
wrangler d1 execute tinbox-restored --remote --file=d1-backup-2026-06-18.sql
If the export included FTS5 shadow tables and the replay errors on them, drop them from the dump and rebuild the index after restore from your migrations, since the search index is derived data.
Step 2: restore R2
Copy the objects back into the bucket. If you mirrored with sync, reverse the direction:
rclone sync backup:tinbox-mail-backup r2:tinbox-mail \
--transfers=32 \
--checksum
If you restored D1 to a point in time, restore R2 to the closest snapshot you have to that same moment. This is exactly why you schedule the two backups close together: the smaller the time gap between the D1 export and the R2 mirror, the fewer dangling keys you have to reconcile.
Step 3: verify the contract
Now confirm every D1 reference resolves to a real R2 object. Pull the keys out of the restored D1:
# Export every attachment r2_key plus the deterministic raw.eml keys
wrangler d1 execute tinbox-restored --remote --json \
--command "SELECT r2_key FROM attachments WHERE r2_key IS NOT NULL" \
> expected-keys.json
Then list what is actually in the bucket and diff the two sets:
# What R2 actually has
rclone lsf --files-only -R r2:tinbox-mail > actual-keys.txt
# Keys D1 expects (extract from the JSON above)
jq -r '.[0].results[].r2_key' expected-keys.json | sort > expected-keys.txt
sort actual-keys.txt -o actual-keys.txt
# Rows pointing at missing objects (the dangerous case)
comm -23 expected-keys.txt actual-keys.txt > dangling-rows.txt
# Objects no row references (orphans: wasted storage, usually harmless)
comm -13 expected-keys.txt actual-keys.txt > orphan-objects.txt
A clean restore produces an empty dangling-rows.txt. If it is not empty, you have message metadata referencing bytes that are not present: decide per case whether to recover the object from an older R2 snapshot or to soft-delete the row so the inbox does not show a broken attachment. Orphan objects are the safer failure: they cost a little storage and can be swept later, but they never show a user a broken message.
This three-step pattern, restore both stores then prove the join, is the difference between "the restore ran without errors" and "the inbox actually works."
What about backing up the marketing and sender state, not just the inbox?
The same two stores hold it, so the same backup covers it. A common worry when consolidating Gmail plus Resend plus Mailchimp into one stack is that you have lost three separate vendor backups. In practice you have gained one consistent one.
- Subscriber lists, audiences, and segments live in D1 tables. They are in your
wrangler d1 exportautomatically. No separate Mailchimp-export dance, no CSV round-trip: the list is rows in your database, and the dump has them. - Sent message bodies and newsletter content live as R2 objects and D1 rows, captured by the same backup as inbound mail.
- The send ledger (which recipient got which message, and the result) is D1 rows, so your delivery history survives a restore. If you have ever tried to reconstruct send history from a transactional vendor after churning off it, you know this is not free elsewhere.
The point is that consolidation simplifies backup rather than complicating it. Three vendors meant three export formats, three retention policies, and three things to forget. One stack means one D1 dump and one R2 mirror cover the inbox, the transactional sender, and the marketing list together. For more on why that consolidation is worth it, see the total cost of owning your email stack and email data portability when you own the database. If you want a concrete list of what a backup like this should let you walk away with, the data ownership checklist enumerates the nine things you should be able to export today.
A minimal backup schedule you can actually run
Here is a defensible baseline. Adjust the cadence to your volume, but do not skip the off-platform copy.
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date -u +%Y-%m-%dT%H%M%SZ)
# 1. Time Travel is automatic (nothing to do); it's your 30-day undo.
# 2. Daily D1 export, off-platform
wrangler d1 export tinbox --remote --output="d1-${STAMP}.sql"
rclone copy "d1-${STAMP}.sql" backup:tinbox-d1-dumps/
# 3. Daily R2 incremental mirror to a SECOND account / provider
rclone sync r2:tinbox-mail backup:tinbox-mail-backup --checksum
# 4. Weekly: keep a dated, append-only R2 snapshot so a bad sync can't
# propagate a mass deletion into the only copy you have.
if [ "$(date -u +%u)" = "7" ]; then
rclone copy r2:tinbox-mail "backup:tinbox-mail-archive/${STAMP}/"
fi
Run it from cron, a GitHub Action, or a tiny always-on box with a scoped Cloudflare API token (one that can read D1 and R2 but not delete production). Keep at least a week of daily D1 dumps and one append-only weekly R2 snapshot, and you can recover from both "yesterday's mistake" and "the account is gone" without ever calling support, because there is no support to call.
Or skip the runbook: Tinbox ships the stack already split for backup
Everything above works on any Cloudflare email stack, but it is a lot to wire up by hand. Tinbox deploys the D1-plus-R2 architecture this guide assumes, with the deterministic R2 key scheme and the attachment r2_key contract already in place, so the backup and restore commands here apply directly to a real deployment rather than to a stack you first have to build.
npx tinbox init yourdomain.com
You still own the backups (that is the whole point of running it on your own account), but you are backing up a stack that was designed to be backed up: two stores, deterministic keys, and a clean join you can verify. The schedule above drops straight onto a fresh deploy.
If you are still weighing whether to own this layer at all, the buy vs build vs self-host comparison lays out the trade, and the Tinbox overview covers what the stack includes beyond storage.
Deploy this yourself: npx tinbox init <domain>, then point the backup script above at your tinbox database and tinbox-mail bucket. The deploy docs cover scoping a backup-only API token so your cron job can never touch production.
Frequently asked questions
Does Cloudflare back up D1 automatically?
Partly. D1 has Time Travel, which keeps a continuous write-ahead log and lets you restore any database to any point in the last 30 days with a single command and no prior setup. That covers accidental deletes and bad migrations within the window. It is not an off-platform backup: it lives inside your Cloudflare account, it is bounded to 30 days, and it does nothing for R2. For real disaster recovery you also need explicit `wrangler d1 export` dumps stored somewhere you control.
Does R2 have point-in-time recovery like D1?
No. R2 has no Time Travel and no built-in versioning equivalent to S3 object versioning at the time of writing, so a deleted or overwritten object is gone unless you replicated it. You are responsible for R2 backups: replicate the bucket on a schedule to a second bucket (ideally in a different account or a different provider) using rclone or a scheduled Worker. R2 has no egress fees, so copying out is cheap.
Why do I need to back up D1 and R2 together?
Because they reference each other. D1 stores the attachment rows and message metadata; R2 stores the actual bytes; the link between them is the r2_key column. If you restore D1 to Tuesday and R2 to Thursday, every message row created Wednesday points at an object that exists, but every object deleted Wednesday is referenced by a row that now dangles. Back them up close together in time and restore them as a matched pair, then run an integrity check that every r2_key resolves.
How do I restore a D1 database from a backup?
For an in-window mistake, use Time Travel: `wrangler d1 time-travel restore tinbox --timestamp=<ISO8601>`. For a full rebuild from an export, create a fresh database and replay the SQL dump: `wrangler d1 execute tinbox-restored --remote --file=backup.sql`. After either, copy R2 objects back into the bucket and verify the r2_key references before pointing the live Worker at the restored database.
Where should email backups live so they survive an account compromise?
Not in the same Cloudflare account as the live stack. If an attacker or a billing lapse takes the account, Time Travel and same-account R2 replicas go with it. Push at least your D1 export and a periodic R2 mirror to a second, separately-credentialed account or another object store (Backblaze B2, S3, even a local NAS). The whole point of owning your email is that no single failure, including the platform's, can erase it.


