ezacto developers

Backup and restore

This document separates the recovery path that exists today from the portable backup format promised by D18. Do not discover that distinction during an incident.

Path Current status Protects
Container physical snapshot available and Docker-tested stopped /data/db.sqlite plus /data/attachments
D1 Time Travel Cloudflare-native, in-place undo D1 database only; not R2 attachments
Portable D18 logical bundle not implemented future Worker/container escape bundle

Container physical snapshots

The physical snapshot is for the single-container layout. It is a directory containing:

snapshot/
├── db.sqlite
├── attachments/
│   └── sha256/<prefix>/<sha256>
└── snapshot.json

snapshot.json records the writer image, source volume, byte size, and SHA-256 of the database and every attachment. It contains no SMTP credentials, signing keys, reverse-proxy settings, or other environment configuration. Back those up separately in a credential store the snapshot destination cannot overwrite.

This is deliberately called a physical snapshot, not a D18 bundle. It has no tables/*.csv, no cross-runtime database conversion, and no activity event.

Take a snapshot

Run these commands from the same reviewed Ezacto checkout used to build the image. Choose a new absolute output path on storage outside the Docker volume:

docker stop --timeout 30 ezacto
node scripts/container-physical-snapshot.mjs backup \
  --container ezacto \
  --output /secure/backups/ezacto-2026-09-01T0100Z
docker start ezacto

The helper accepts only an exited source container with exactly one writable named volume at /data, and only when that container is the volume's sole Docker container reference. This prevents a second running or stopped container from sharing the snapshot source. It refuses SQLite WAL/SHM sidecars, unexpected root files, symlinks, special files, invalid content-addressed attachment paths, or bytes whose hash differs from the attachment key. A failure removes only the new output directory it created; it never changes the source volume.

Re-verify a copied snapshot at any time:

node scripts/container-physical-snapshot.mjs verify \
  --bundle /secure/backups/ezacto-2026-09-01T0100Z

Copy the verified directory to storage with independent credentials and retention. Versioning or immutability protects old copies from a compromised host deleting its own recovery points.

Restore into a new volume

Prerequisites:

Verify first, create the target volume, and restore:

node scripts/container-physical-snapshot.mjs verify \
  --bundle /secure/backups/ezacto-2026-09-01T0100Z
docker volume create ezacto-restored-data
node scripts/container-physical-snapshot.mjs restore \
  --bundle /secure/backups/ezacto-2026-09-01T0100Z \
  --volume ezacto-restored-data \
  --image ezacto:YOUR_REVIEWED_COMMIT

The restore helper re-verifies every source hash, refuses any target volume already referenced by a container, rechecks that it is empty, copies only db.sqlite and attachments, applies the production image's UID/GID 1000 ownership with directory mode 0700 and file mode 0600, then copies the target back out and compares every byte to snapshot.json. If a restore fails after writing begins, discard that new target volume and retry with another empty volume; never merge a partial restore into existing data.

Start a separate validation container without reusing the old container name or volume:

docker run --detach \
  --name ezacto-restored \
  --publish 127.0.0.1:3001:3000 \
  --volume ezacto-restored-data:/data \
  --env-file /secure/path/ezacto.env \
  ezacto:YOUR_REVIEWED_COMMIT
docker inspect --format '{{.State.Health.Status}}' ezacto-restored
curl -fsS http://127.0.0.1:3001/healthz

Startup performs PRAGMA quick_check, foreign_key_check, and pending migrations before the listener opens. Sign in and verify a known time entry and download at least one known attachment before moving the reverse proxy. Keep the old stopped container and volume until validation and cutover are complete.

The CI test tagged [e2e:backup-restore] exercises these same helper commands. It proves shared snapshot sources and already-attached restore targets fail closed, then creates a real instance, uploads an attachment, stops and snapshots it, deletes the source volume, restores a new volume, and proves both API row state and exact attachment bytes through the restored application.

Worker recovery today

Cloudflare D1 Time Travel can restore the database in place to a recent UTC timestamp. It is destructive, cancels in-flight database work, and returns the previous bookmark so the operation can be undone. Confirm the target database name and timestamp with another operator, preserve the current bookmark, and use Cloudflare's current Time Travel procedure.

Time Travel does not restore the ATTACHMENTS R2 bucket. If that bucket is unchanged, restored database rows can continue to reference its immutable content-addressed objects; if R2 objects are missing, Time Travel is not a complete recovery. There is currently no supported command in this repository that coordinates a D1 database export with an R2 attachment export or converts that pair into a container snapshot.

Portable D18 bundle: unavailable

D18 requires one verifiable, vendor-independent artifact containing a plain SQLite database, one CSV per table, content-addressed attachments, a manifest with per-file hashes and row counts, and its own restore instructions. Ezacto does not yet produce that artifact. A Harvest migration snapshot is a different input format and must not be presented as an Ezacto backup.

The exact missing implementation is tracked by:

Until #28 and #37 are complete, there is no honest portable Worker restore command and no Worker-to-container escape procedure. The container physical snapshot above is independently usable but does not close those dependencies.