Skip to main content

Backup, restore, and disaster recovery

This runbook is for self-hosted Steward operators. Test it against the exact release and database major version used in production. A backup that has not been restored in a drill is not a recovery plan.
Critical: a database dump is not a complete Steward backup. Keep the database, its schema/release metadata, and the out-of-band secrets below as one versioned recovery set. The secrets are not stored in the database. Loss of an encryption root can make otherwise intact ciphertext permanently unrecoverable.

Recovery set and trust boundaries

PostgreSQL

Back up the whole database in one consistent pg_dump. Do not select only “important” tables. The coherent unit includes, among other tables:
  • encrypted wallet keys, chain keys, OAuth tokens, credentials, routes, policy, tenant, agent, workspace, provider-account, operation, grant, and binding rows;
  • approval_queue, pending_proxy_requests, transactions, and intents, which preserve the approval lifecycle and encrypted queued request bodies;
  • execution_authorization_nonces, including its consumed state and the v2 dispatch_state (none, claimed, dispatched, terminal, or outcome_unknown);
  • audit_events, audit_chain_heads, audit_checkpoints, provider-action audit rows/outbox, and proxy audit rows.
A table-only dump can sever foreign keys, approval-to-intent bindings, nonce replay protection, or audit continuity. Never restore a nonce or approvals table from a different point in time than the rest of the database. Record alongside the dump:
  • Steward git commit or image digest and release version;
  • PostgreSQL server major version and pg_dump --version;
  • latest applied migration (packages/db/drizzle/ for PostgreSQL, and __steward_migrations for PGLite);
  • backup start/end UTC timestamps and a SHA-256 digest of the encrypted archive;
  • the recovery-set identifier shared with the secret escrow and, if retained, Redis snapshot.

Out-of-band secrets

Export secrets through the operator’s secret store, not by printing .env to a terminal or copying values into a ticket. Store the export encrypted, separately from the database dump, with at least two tested custodians. Files containing secrets must be owned by the service account and mode 0600; parent directories must be 0700. The current steward doctor and Compose production roots are: Also escrow every enabled deployment-specific credential that is needed after a restore, including STEWARD_PLATFORM_KEYS, tenant API keys returned only once, STEWARD_PROXY_REQUEST_SIGNING_SECRETS, OAuth client secrets, email credentials, webhook key overrides, and optional sidecar credentials. Their loss has the effect of that subsystem’s documented rotation or re-enrollment path, not database decryption. STEWARD_PLATFORM_KEYS and raw tenant API keys are not recoverable from stored hashes. The KDF salt is configuration, not a per-row database salt. Row-level IVs, authentication tags, and salts are already in the full database dump, but they do not replace STEWARD_MASTER_PASSWORD plus STEWARD_KDF_SALT.

PostgreSQL backup

Use a pg_dump client with the same major version as the server. PostgreSQL supports restoring a logical dump into a newer server in many cases, but test that path; do not restore with an older pg_restore than the pg_dump that created the archive. Use a protected passfile instead of embedding a password in the URI or command. The placeholders below are non-secret identifiers:
pg_dump is transactionally consistent for PostgreSQL data. For the smallest uncertainty window around in-flight external dispatches, stop API and proxy writers before the dump. A hot dump is usable, but recovery must still treat any captured in-flight dispatch as uncertain. For the root Compose stack, keep application writers stopped while dumping from the bundled PostgreSQL 16 container:
The password remains inside Compose/container configuration and is not echoed. Do not use docker compose down -v; -v deletes named data volumes.

Redis

PostgreSQL is the durable source for Steward records. Redis nevertheless holds rate-limit, spend-tracking, and challenge/cache state. Losing live spend counters can weaken enforcement until their windows expire. During a coordinated backup, freeze API/proxy writers and either capture a tested Redis snapshot with the same recovery-set identifier or document a conservative cold-start policy that keeps the proxy unavailable until affected spend windows expire. Never bring up a restored proxy with silently empty counters when a spending-limit policy expects prior usage.

Embedded PGLite backup

PGLite is intended for local/development use and does not use the Compose PostgreSQL volume. Its persistent directory is STEWARD_PGLITE_PATH, defaulting to ~/.steward/data. STEWARD_PGLITE_MEMORY=true has no durable state and cannot be backed up after the process exits. Do not copy a live PGLite directory. Stop the sole API/embedded process, confirm no process has the directory open, and archive the entire directory, including its migration metadata:
Replace /var/lib/steward/pglite-data with the configured path. Restore only to a stopped instance, preserve ownership/mode, and start the same Steward release first. A PGLite directory archive is not a portable pg_restore archive.

Restore order

  1. Declare an incident and freeze writers. Block inbound traffic at the operator-controlled reverse proxy/firewall, then stop every Steward API, proxy, worker, and embedded process. Stop external schedulers and agents that submit work. Keep them stopped through reconciliation.
  2. Choose one recovery set. Verify checksums, timestamps, release/database versions, secret-set identifier, and chain of custody. Never combine a newer database with older nonce/audit tables or a mismatched root-secret set.
  3. Prepare compatible software. Restore into the recorded PostgreSQL major version with a pg_restore version at least as new as the dump producer. Check out the recorded Steward release. Do not let a newer API migrate the target before the base restore is complete.
  4. Restore the database while the application is stopped. Restore into an empty database. For a replacement database owned by the intended role:
    --clean is destructive to the selected target. Triple-check PGHOST and PGDATABASE; never point this command at a healthy production database.
  5. Restore environment secrets before application startup. Install the exact STEWARD_MASTER_PASSWORD, STEWARD_KDF_SALT, audit keys, execution-auth key list, JWT secret, database credentials, and enabled subsystem credentials. Set files to 0600. Do not print values in logs or shell tracing.
  6. Resolve migration compatibility. First run the recorded release against the restored schema. Compare the migration ledger to the checked-out packages/db/drizzle/ files. Then upgrade one tested release step at a time. Run DATABASE_URL="$STEWARD_MIGRATION_DATABASE_URL" bun run --cwd packages/api migrate with the restored plugin selection, reconcile ownership using rls-bootstrap.sql through the provider-superuser-equivalent operator, and activate through the direct migration login. Keep both privileged URLs out of the API, set SKIP_MIGRATIONS=true, and start it only after all three steps succeed. Never run an older release against a schema already migrated by a newer release.
  7. Restore or conservatively reset Redis. Restore the matched snapshot, or enforce the documented cold-start hold for spend windows. Redis recovery must not delay inspection of durable PostgreSQL state.
  8. Start API only, with dispatch blocked. Keep the external proxy, agents, workers, and ingress frozen. Confirm /health and /ready, then run doctor and the database/audit checks below.
  9. Reconcile approvals and execution state. Follow the next section. Do not make direct SQL status edits merely to make rows look terminal.
  10. Resume in stages. Enable API ingress, then proxy ingress and workers only after a named operator signs off. Monitor denied/replayed authorization, audit-verification, spend-limit, decrypt, and migration errors.
For PGLite, replace step 4 with extraction of the complete archive into an empty configured STEWARD_PGLITE_PATH, using the original owner and permissions.

In-flight reconciliation, fail closed

A crash-consistent restore cannot prove what an external provider did after the backup boundary. Steward therefore makes no exactly-once recovery claim. Database single-use and idempotency controls prevent known duplicate dispatches, but cannot turn an uncertain external side effect into proof. While the proxy remains frozen, inventory at least:
Disposition rules:
  • claimed, dispatched, and outcome_unknown executions are never blindly retried or reset to none. Quarantine them. Check the provider using its operation/request/idempotency identifier and independent records. If a supported reconciliation path can establish the outcome, use that path and retain its audit evidence. The current repository does not expose a generic operator reconciliation command, so do not invent one or update rows by hand. If the outcome cannot be proven, leave it failed closed and create a new human- reviewed intent only after assessing duplicate-side-effect risk.
  • A claimed row is not proof that dispatch happened, and a missing terminal event is not proof that it did not. Treat both directions as uncertain.
  • Expired active authorizations must not be extended or replayed. The governed dispatch claim checks database time and fails closed. Recreate approval and authorization through the normal flow when safe.
  • Pending/approved approvals and queued proxy requests may have expired or may bind to policy, routes, secrets, operations, or external facts that changed after the backup. Let normal lifecycle validation expire/reject stale work. Require fresh review for restored approved-but-unconsumed work; do not resume it merely because the restored status says approved.
  • Record the incident decision, evidence checked, and replacement intent IDs in the audit/incident record. Preserve the restored database snapshot for forensics.

Verification before reopening

Run the existing doctor from the restored release. It reports presence/length, not secret values:
Doctor checks configuration and health, not decryptability of every ciphertext. Using a dedicated test tenant/agent, perform a read/decrypt/sign smoke test that does not broadcast value, plus an OAuth/secret decrypt test for each enabled backend. Failure means keep ingress closed. For every tenant, read the expected head while writers remain frozen:
Call the existing authenticated POST /audit/verify endpoint starting at fromSeq=1, with requireHead=true. The endpoint accepts at most 10,000 rows, so use contiguous ranges and set the final toSeq to that tenant’s exact expected_seq; do not use a guessed upper bound. It recomputes the HMAC chain and checks the in-database high-water mark. Keep authentication in a protected curl config or operator client, not command history. Every result must be valid, cover the requested range, and report no break. This check requires the original STEWARD_AUDIT_HMAC_KEY. Then create and verify signed evidence bundles with the existing CLI/offline verifier. The CLI invokes scripts/verify-evidence-bundle.mjs when --verify is set. Export contiguous ranges of at most 10,000 events, ending the final range at the exact head sequence:
This requires tenant authentication configured for the CLI and the restored STEWARD_AUDIT_SIGNING_KEY. Compare the latest restored checkpoint/public-key identity and chain head with a pre-incident bundle held outside the database. An offline bundle can validate its embedded signature and content commitment, but does not by itself prove that no newer database tail was lost. A mismatch, missing expected tail, unknown signer, HMAC failure, or head failure blocks reopening. Also verify row counts/critical inventories against the backup manifest, inspect migration status, ensure no service used a development secret fallback, and test spend-limit behavior before unfreezing the proxy.

Tested disaster-recovery drill checklist

Run this at an interval justified by the RPO/RTO target and after material schema, key, migration, or deployment changes. Never drill by restoring over production.
  • Assign incident commander, database operator, secret custodian, and independent verifier.
  • Create a tagged recovery set and record release/image, PostgreSQL and tool versions, migration tip, timestamps, checksums, and escrow identifier.
  • Provision an isolated target with outbound provider calls blocked.
  • Restore the full PostgreSQL dump, exact out-of-band secret set, and matched Redis snapshot or documented conservative hold.
  • Start the recorded Steward release first; capture migration and readiness output. If testing an upgrade, perform it only after base restore succeeds.
  • Run strict doctor, /health, /ready, ciphertext smoke tests, authenticated /audit/verify?fromSeq=1&requireHead=true for every tenant, and offline evidence-bundle verification.
  • Compare critical table counts, audit heads/checkpoints, and expected latest transaction/approval/nonce timestamps with the source manifest.
  • Inject or retain representative pending approval plus claimed, dispatched, and outcome_unknown nonce cases. Confirm the drill procedure quarantines them and sends no provider request.
  • Confirm expired authorization and stale approval paths fail closed.
  • Confirm proxy remains blocked with missing execution-auth/audit roots and with unavailable required Redis spend state.
  • Exercise staged ingress reopening using a non-value test operation, then close ingress again and preserve evidence.
  • Measure detection, restore, verification, reconciliation, and total recovery times. Record actual recovered timestamp and data loss.
  • Destroy the isolated copy and secret material under the operator’s secure media procedure. File owners and due dates for every failed step.
A drill passes only when all required checks execute and pass. A skipped secret, audit, nonce, or approval assertion is a failed drill.

RPO/RTO worksheet

Fill this per deployment. Do not copy aspirational values into an SLA until a drill demonstrates them. RPO is bounded by the oldest component in a coherent recovery set, not just the latest database dump. RTO includes human review of uncertain external dispatches; do not hide that time by reopening the proxy before reconciliation.