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 consistentpg_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 v2dispatch_state(none,claimed,dispatched, terminal, oroutcome_unknown);audit_events,audit_chain_heads,audit_checkpoints, provider-action audit rows/outbox, and proxy audit rows.
- 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_migrationsfor 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 apg_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:
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 isSTEWARD_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:
/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
- 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.
- 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.
-
Prepare compatible software. Restore into the recorded PostgreSQL major
version with a
pg_restoreversion 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. -
Restore the database while the application is stopped. Restore into an
empty database. For a replacement database owned by the intended role:
--cleanis destructive to the selected target. Triple-checkPGHOSTandPGDATABASE; never point this command at a healthy production database. -
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 to0600. Do not print values in logs or shell tracing. -
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. RunDATABASE_URL="$STEWARD_MIGRATION_DATABASE_URL" bun run --cwd packages/api migratewith the restored plugin selection, reconcile ownership usingrls-bootstrap.sqlthrough the provider-superuser-equivalent operator, and activate through the direct migration login. Keep both privileged URLs out of the API, setSKIP_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. - 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.
-
Start API only, with dispatch blocked. Keep the external proxy, agents,
workers, and ingress frozen. Confirm
/healthand/ready, then run doctor and the database/audit checks below. - Reconcile approvals and execution state. Follow the next section. Do not make direct SQL status edits merely to make rows look terminal.
- 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.
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:claimed,dispatched, andoutcome_unknownexecutions are never blindly retried or reset tonone. 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
claimedrow 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: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:
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=truefor 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, andoutcome_unknownnonce 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.
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.