Skip to content

guides

Upgrade your self-hosted email stack without breaking sending

May 25, 2026

#self-hosted-email#d1-migrations#cloudflare-workers#upgrades#rollback
Upgrade your self-hosted email stack without breaking sending
Visual: Magnus Obscura on okslop

The price of owning your email stack is owning its upgrades. A botched schema migration or a half-deployed worker does not open a support ticket somewhere; it stops your mail, and you are the one who has to bring it back. This is the runbook for upgrading without that happening.

When you move off Gmail and Resend and Mailchimp onto your own Cloudflare account, you stop paying per contact and per email, and you stop handing your data to three vendors. You also inherit a job those vendors quietly did: shipping new versions without taking the service down. SaaS rolls forward behind a load balancer and you never notice. On your own D1 and R2 and Workers, the rollout is yours, and the failure modes are real: a migration that locks a table the live worker reads, a deploy that ships code expecting a column the database does not have yet, or a rollback you did not plan and now cannot perform.

The good news is that a Cloudflare-native email stack has a small, well-defined upgrade surface, and the tooling underneath it (wrangler migrations, D1 Time Travel, atomic worker deploys) is genuinely good. The trap is treating an upgrade as a single deploy command instead of an ordered procedure with a rollback. This guide walks the safe order, the migration patterns that keep mail flowing during the swap, how to roll back each kind of failure, and how an agency fans the same pass across many tenants.

What can actually break when you upgrade a self-hosted email stack?

Three things, in rough order of how badly they bite: a schema migration that is incompatible with the code currently running, a deploy that ships before its migration, and an upgrade with no rollback. Everything in this guide is organized around those three.

A Cloudflare email stack keeps its state in two stores. D1 (SQLite) holds the searchable metadata: message rows, threads, attachment rows, subscriber and audience lists, the send ledger, the FTS5 search index, and the auth tables. R2 holds the raw .eml bodies and attachment blobs. An upgrade rarely touches R2 (object bytes do not have a schema), so the danger lives almost entirely in D1 and in the worker code that reads it.

The killer combination is a schema and code mismatch during the deploy window. A worker deploy on Cloudflare is atomic per version, but a migration plus a deploy is two operations, and for a moment one is done and the other is not. If you migrate first and the old code does not understand the new schema, or you deploy first and the new code expects a column that is not there yet, inbound mail handling and outbound sends can throw in that gap. The whole procedure below exists to shrink and survive that gap.

What is the safe order of operations for an upgrade?

Fetch, read, stage, then roll forward production as one idempotent pass with a rollback ready. Concretely: snapshot the current version and take a restore point, fetch the new code, read the migration diff, apply to a staging or local database and smoke-test, then run install → migrate → deploy against production.

The fetch step is deliberately yours, not the tool's. Your deployment is your clone with your customizations in the app layer, so getting newer code is a git pull (which may need conflict resolution you have to make decisions about) or a pnpm update tinbox for npm-linked tenants. Nothing should silently overwrite your customizations.

After that, the tail is deterministic and error-prone, which is exactly the kind of thing to automate. tinbox update, run from the project directory, does install → migrate → deploy in that order and reports the running version at the end:

# From your project directory, with the new code already pulled:
tinbox update --dry-run     # print the plan: install → migrate (remote D1) → deploy

# Looks right? Run it for real.
tinbox update
Tinbox · update

  CLI version: v0.9.0
  ! A newer Tinbox is published: v0.10.0 (make sure you've pulled it; see below)

  This applies code you've already fetched. If you haven't yet:
    • git-cloned tenants:  git pull   (resolve conflicts in your app/ layer)
    • npm-linked tenants:  pnpm update tinbox

▸ Install
▸ Migrate
  binding:   DB
  target:    remote D1
  migrations applied (wrangler tracks what's already run; safe to re-run)
▸ Deploy
  ✓ Updated. Live at https://mail.acme.com
  Confirm in the app: Settings → About shows the running version.

The single most important property here: every step is idempotent. If the deploy fails halfway, you re-run tinbox update and it picks up where it stopped. Migrations already applied are skipped; the install is a no-op if deps are current. There is nothing half-applied that a re-run will not fix. That is what makes the procedure safe to operate by hand at 9pm.

How do I know what a migration is about to do before I run it?

Read the diff between the migrations the new code ships and the ones your database has already applied. The migrations live as plain SQL files in a migrations/ directory, and wrangler records which ones it has run in a d1_migrations table, so the delta is exactly what is about to execute.

# What has my database already applied?
wrangler d1 execute tinbox --remote \
  --command "SELECT name FROM d1_migrations ORDER BY id"

# What migrations does the new code ship?
ls migrations/
0000_baseline.sql
0001_messages_fts.sql
...
0022_user_org_roles.sql
0023_fluffy_warlock.sql
0024_tricky_dexter_bennett.sql   <- new in this version

Open the new file and actually read it. You are looking for one thing above all: is this migration additive or destructive? Additive migrations (new tables, new columns with defaults, new indexes) are safe to apply while the old worker is still running, because the old code simply ignores the new columns. Destructive migrations (DROP COLUMN, ALTER ... RENAME, a DELETE as part of a backfill) are the ones that break the running worker, because the old code is still reading what the migration just removed.

-- SAFE during a live deploy: old code ignores the new column.
ALTER TABLE messages ADD COLUMN intent_label TEXT;

-- DANGEROUS mid-deploy: the running worker still SELECTs old_status.
ALTER TABLE messages DROP COLUMN old_status;

If the diff is purely additive, you can migrate and deploy in either order with confidence. If it contains a destructive change, treat it as a two-release operation (covered below) or accept a brief maintenance window. Reading the diff is thirty seconds that tells you which kind of upgrade you are about to do.

How do I keep mail flowing while the migration runs?

Keep migrations additive so the old and new worker can both run against the new schema, and split any destructive change across two releases. This is the expand-and-contract pattern, and it is the difference between a zero-downtime upgrade and a maintenance window.

The window you are trying to survive is the gap between "migration applied" and "new worker live." During that gap, the still-running old worker handles inbound mail and outbound sends against the already-migrated schema. As long as the migration only added things, the old code is fine. The rule is: never remove or rename in the same release that stops using something.

To rename a column or change its meaning, do it in two releases:

-- Release N (additive only): add the new column, backfill it, dual-write in code.
ALTER TABLE subscribers ADD COLUMN status TEXT DEFAULT 'active';
UPDATE subscribers SET status = CASE WHEN unsubscribed = 1 THEN 'unsubscribed' ELSE 'active' END;

Ship release N, let it run, confirm the new column is correct. Then in release N+1, once no running code reads the old column anymore:

-- Release N+1 (now safe, because nothing reads `unsubscribed` anymore):
ALTER TABLE subscribers DROP COLUMN unsubscribed;

For the email-specific hot paths, the things you must not break for even a few seconds are inbound ingest (the worker that receives mail from Cloudflare Email Routing and writes it to D1 and R2) and the transactional sender. Keep their tables additive across any single release and they keep working through the deploy. The marketing and newsletter tables tolerate a brief inconsistency better, because a campaign send is a deliberate action you control the timing of, not a continuous stream like inbound mail. If you have a destructive change you cannot avoid, schedule it when no campaign is sending and accept that a handful of in-flight inbound messages may retry. Cloudflare Email Routing retries on a non-2xx from your worker, so a short blip is recoverable rather than lost.

For more on why these two paths deserve different treatment, see separating transactional and marketing email.

How do I test the upgrade before it touches production?

Apply the migrations to a local or staging copy first and smoke-test send and receive there. The same tinbox tooling targets a non-production database, so your rehearsal runs the exact code path the real upgrade will.

The cheapest rehearsal is local. After pulling the new code, apply migrations to the local sqlite and run the app against it:

# Apply the new migrations to LOCAL sqlite, not remote D1:
tinbox update --local
# (equivalently: tinbox migrate --local, which skips deploy entirely)

--local migrates the local dev database and skips the deploy, because there is nothing remote to ship to. Run the inbox locally, send a test message, receive one, and confirm search and the newsletter list still load. If a migration is going to fail or a query is going to break against the new schema, you find out here for free.

For higher fidelity, keep a staging deployment: a second, throwaway Tinbox project on a subdomain you do not route real mail to. Point a separate D1 database at it and run the full tinbox update there first. Because each Tinbox deployment is independent (one domain per deploy), a staging deploy is just another npx tinbox init staging.acme.com away, and it exercises the real remote-D1 migration path and the real wrangler deploy, not just local sqlite.

Either way, the test you actually care about is behavioral, not "did the migration run." Send a message and confirm it arrives; receive a message and confirm it lands in the inbox and is searchable. A migration can apply cleanly and still break a query the worker depends on. Smoke-testing the two hot paths catches that.

How do I roll back when an upgrade goes wrong?

You have two independent rollbacks, one per failure mode: D1 Time Travel for a bad migration, and a redeploy of the previous worker version for bad code. Decide which you need based on what broke, and have both ready before you start.

Before you migrate, take a restore point. This is the single step people skip and regret. Record the timestamp and take an export:

# Note the current restore window / bookmark:
wrangler d1 time-travel info tinbox --remote

# And a portable dump you store off-platform, just in case:
wrangler d1 export tinbox --remote --output=pre-upgrade-$(date -u +%Y%m%dT%H%M%SZ).sql

If the migration corrupted or deleted data, rewind D1 to just before it ran. Time Travel keeps a continuous write-ahead log and restores to any point in the last 30 days with no prior setup:

wrangler d1 time-travel restore tinbox --remote --timestamp=2026-06-19T20:55:00Z

That fixes the database, but remember the schema and code must agree: after a Time Travel restore, the live worker is now running new code against an old schema, so you also redeploy the matching old worker version. If the deploy shipped broken code but the migration was fine, you only need the code rollback. Cloudflare keeps previous worker versions, so you can roll the worker back to the last good deployment from the dashboard or by redeploying your previous git revision:

git checkout <last-good-tag>
tinbox update --skip-migrate    # redeploy old code; leave the (good) schema alone

--skip-migrate is the important flag here: when only the code is bad, you do not want to touch the database. The deeper lesson is that code and schema are two separable things with two separate rollbacks, and an upgrade plan that conflates them leaves you unable to fix one without disturbing the other. For the full backup-and-restore story behind these rollbacks, see backing up and restoring a Cloudflare email stack.

How does an agency upgrade many tenants without a bad night?

Canary one tenant, verify mail flows, then fan the same idempotent pass across the rest. There is no global upgrade button, because each tenant is a separate deployment (one brand and domain per deploy, many deploys per Cloudflare account), and that separation is exactly what makes a staged rollout safe.

Start with your lowest-stakes tenant, ideally an internal one or a staging deploy. Run the full procedure there: read the diff, stage, tinbox update, smoke-test. If the new version has a problem, you found it on a tenant whose mail you can afford to interrupt, not on your biggest client.

Once the canary is healthy, the rest is a loop. Because tinbox update is idempotent and migrations are version-tracked, you can run it unattended across every project directory and safely re-run any tenant that errored:

#!/usr/bin/env bash
set -uo pipefail
# Upgrade a roster of tenants; record failures, never double-apply.
for dir in tenants/*/; do
  echo "── ${dir} ──"
  ( cd "$dir" && git pull --ff-only && tinbox update ) \
    || echo "FAILED: ${dir}" >> upgrade-failures.txt
done
echo "Re-run the failures by hand:"; cat upgrade-failures.txt 2>/dev/null

A tenant that fails midway is not corrupted, because the migration step is idempotent and the deploy is atomic per version. You re-run that one directory and it resumes. Keep the per-tenant restore points (the Time Travel window is per database) so a single bad tenant rolls back without touching the others. For the wider agency operating model, see running self-hosted email for agency clients, and the tips for running multi-tenant email on one Cloudflare account cover the per-tenant separation that makes this staged rollout safe.

A minimal upgrade checklist you can actually follow

Here is the whole procedure compressed to something you can run down a list. Do not skip the restore point.

  1. Snapshot. Record the running version (Settings → About). Take a Time Travel bookmark and a wrangler d1 export dump stored off-platform.
  2. Fetch. git pull (resolve app-layer conflicts) or pnpm update tinbox. Your step; nothing overwrites your customizations silently.
  3. Read the diff. List the new migration files and open them. Destructive means two-release expand-and-contract or a maintenance window.
  4. Stage. tinbox update --local (or a real staging deploy). Smoke-test send, receive, search, and the subscriber list.
  5. Roll forward. tinbox update --dry-run, eyeball the plan, then tinbox update. A failure mid-run is fixed by re-running.
  6. Verify. Settings → About shows the new version. Send and receive a live test message.
  7. If it breaks. Migration bad: Time Travel restore, redeploy matching code. Code bad: git checkout last-good, then tinbox update --skip-migrate.

Two of those seven steps are "take a backup" and "have a rollback." That is the difference between an upgrade and an outage.

Or skip the orchestration: Tinbox ships the upgrade path

Everything above is the general shape of upgrading any Cloudflare email stack, but wiring the install → migrate → deploy tail by hand, tracking which migrations ran, and keeping the steps idempotent is exactly the kind of work you do not want to redo per tenant. Tinbox bundles that tail behind one command.

# From your project directory, after pulling new code:
tinbox update

tinbox update runs install → migrate → deploy in order, delegates migration tracking to wrangler's d1_migrations table (so re-running never double-applies), targets local sqlite with --local for rehearsals, and reports the running version at the end so Settings → About is your source of truth. The fetch step stays yours, because your customizations are yours. The destructive, repetitive, easy-to-get-wrong part is automated and idempotent. The rollbacks (D1 Time Travel for schema, worker version history for code) are Cloudflare primitives you already have.

If you have not stood up the stack yet, the Tinbox overview covers what a deployment includes, what to know before you run npx tinbox init sets expectations for the first deploy, and the buy vs build vs self-host comparison lays out the trade you are making when you take ownership of the upgrade path in the first place.

Deploy this yourself: npx tinbox init <domain>, then upgrade it with tinbox update --dry-run to preview and tinbox update to roll forward. The deploy docs cover staging deployments and scoping a Cloudflare API token that can migrate and deploy but not delete production.

Frequently asked questions

How do I safely upgrade a self-hosted Cloudflare email stack?

Fetch the new code (git pull or a package update), read the migration diff so you know what schema changes are coming, apply the migrations to a staging or local database and smoke-test there, then run the same install → migrate → deploy pass against production. D1 migrations are tracked in a `d1_migrations` table so re-running is idempotent, and D1 Time Travel gives you a 30-day point-in-time rollback if a migration goes wrong. Upgrade one deployment first as a canary before touching the rest.

Will a D1 migration stop my email from being sent or received?

It can, if the migration changes a table the live worker reads or writes while the old worker is still running. The safe pattern is to keep migrations additive (add columns and tables, backfill, then switch reads) so the old and new code can both run against the new schema during the deploy. Destructive migrations (dropping or renaming columns) are the ones that break sending mid-deploy, so split those into add-now, remove-later steps across two releases.

How do I roll back a bad migration on D1?

Use D1 Time Travel: `wrangler d1 time-travel restore <db> --remote --timestamp=<ISO8601>` rewinds the database to any point in the last 30 days, including the moment just before the migration ran. That fixes the data, but you must also redeploy the matching old worker version so the code and schema agree again. Take a timestamp (or a `wrangler d1 export` dump) right before you migrate so you know exactly where to rewind to.

Does `tinbox update` automate the upgrade?

It automates the deterministic tail: install dependencies, apply the bundled D1 migrations, deploy the worker, and report the running version. It does not fetch the new code for you, because that is your clone with your customizations: you git pull (and resolve any conflicts in your app layer) or update the package first, then run `tinbox update`. Every step is idempotent, so a re-run after a hiccup resumes rather than double-applying.

How do agencies upgrade many self-hosted tenants at once?

Each tenant is a separate deployment (one brand and domain per deploy, many deploys per Cloudflare account), so there is no single global upgrade button. Upgrade one low-stakes tenant as a canary, verify mail flows, then script the same fetch → migrate → deploy pass across the rest. Because migrations are idempotent and version-tracked, you can run the loop unattended and re-run any tenant that failed without corrupting the ones that succeeded.