Database Backup and Restore Runbook
Operational guide for verifying that the Guardian RDS database is backed up
and for restoring it after data loss. Companion to the durability overview in
PRODUCTION.md (what is
guaranteed) and architecture/infra.md (how the
stack fits together).
Audience: operators with AWS (RDS, ECS, Secrets Manager) and Terraform access for the target Guardian stack.
All Guardian state lives in the stack's RDS instance
(<stack>-postgres) — account state, deltas, proposals, account metadata,
and audit rows. The server tasks are stateless; nothing else needs to be
restored.
What the stack backs up for you
- Automated backups. A daily snapshot plus continuous WAL archiving,
retained for
rds_backup_retention_days(default 7). This enables restore to any second within the window, up toLatestRestorableTime(typically within ~5 minutes of now). - Final snapshot on destroy. In the prod stage,
./scripts/aws-deploy.sh cleanuptakes a final snapshot named<stack>-postgres-finalbefore deleting the instance. - Deletion protection. In the prod stage the instance refuses deletion
until
rds_deletion_protectionis explicitly turned off.
What it does not back up:
- Terraform state. State files are local to the deploy host (see
architecture/infra.md). - Secrets Manager values. The stack-managed
DATABASE_URLsecret is recreated by Terraform, but the ACK signing keys and — critically — the storage encryption key are not derivable from anything. If storage encryption is enabled, a database backup without the key is ciphertext: keep an out-of-band copy of the key secret persecrets.md.
Verify backups (do this now, not during an incident)
aws rds describe-db-instances \
--db-instance-identifier <stack>-postgres \
--query 'DBInstances[0].{Retention:BackupRetentionPeriod,DeletionProtection:DeletionProtection,MultiAZ:MultiAZ,LatestRestorableTime:LatestRestorableTime}'
aws rds describe-db-snapshots \
--db-instance-identifier <stack>-postgres \
--query 'DBSnapshots[*].{Id:DBSnapshotIdentifier,Created:SnapshotCreateTime,Status:Status}'
For a prod stack, expect Retention >= 7, DeletionProtection: true, a
recent LatestRestorableTime, and at least one available snapshot. If
retention is 0, backups are off — fix the stack before anything else.
Restore procedure (stack intact)
This is the path for data loss while the stack still exists — bad data was written, a migration went wrong, the instance failed. If the whole stack was destroyed, start at Full-stack recovery instead; it recreates the prerequisites these steps reference and then re-enters here.
RDS restores never overwrite an instance in place: both point-in-time and
snapshot restores create a new instance. The stack's DATABASE_URL
secret is derived by Terraform from the database endpoint (in prod, the RDS
Proxy endpoint, whose target is the instance), and endpoints follow the
instance identifier. So the procedure is: restore to a temporary
identifier, then swap names so the restored instance answers at the original
endpoint, then point Terraform state at the restored instance and re-apply.
The state step is not optional: the AWS provider (v5) tracks
aws_db_instance by its DbiResourceId, which survives renames — after
the swap, Terraform still tracks the renamed-away old instance, and an apply
without the state fix would try to rename it back onto the restored one.
Step 4 covers it.
1. Stop writes
Scale the server to zero so no deltas land while the database is swapped. On a stack with ECS autoscaling (the prod default), pin the autoscaling range first or it will immediately scale back up:
aws application-autoscaling register-scalable-target \
--service-namespace ecs \
--resource-id service/<stack>-cluster/<stack>-server \
--scalable-dimension ecs:service:DesiredCount \
--min-capacity 0 --max-capacity 0
aws ecs update-service --cluster <stack>-cluster \
--service <stack>-server --desired-count 0
On a dev stack autoscaling is off by default — skip the
register-scalable-target call (it would create a scaling target Terraform
does not manage) and run only update-service.
The final terraform apply in step 4 restores both settings.
2. Restore to a temporary identifier
Point-in-time (preferred — pick the moment just before the loss):
aws rds restore-db-instance-to-point-in-time \
--source-db-instance-identifier <stack>-postgres \
--target-db-instance-identifier <stack>-postgres-restored \
--restore-time 2026-08-04T12:00:00Z \
--db-subnet-group-name <stack>-postgres-subnets \
--vpc-security-group-ids <postgres-sg-id>
Or from a specific snapshot (a daily automated one, or the final snapshot during full-stack recovery):
aws rds restore-db-instance-from-db-snapshot \
--db-instance-identifier <stack>-postgres-restored \
--db-snapshot-identifier <snapshot-id> \
--db-subnet-group-name <stack>-postgres-subnets \
--vpc-security-group-ids <postgres-sg-id>
Pass the subnet group and security group explicitly — restores do not inherit them from the source. Find the security group ID with:
aws ec2 describe-security-groups \
--filters Name=group-name,Values=<stack>-postgres-sg \
--query 'SecurityGroups[0].GroupId' --output text
Wait for the instance to become available
(aws rds wait db-instance-available --db-instance-identifier <stack>-postgres-restored).
3. Swap identifiers
aws rds modify-db-instance --db-instance-identifier <stack>-postgres \
--new-db-instance-identifier <stack>-postgres-old --apply-immediately
aws rds wait db-instance-available --db-instance-identifier <stack>-postgres-old
aws rds modify-db-instance --db-instance-identifier <stack>-postgres-restored \
--new-db-instance-identifier <stack>-postgres --apply-immediately
aws rds wait db-instance-available --db-instance-identifier <stack>-postgres
Skip the first rename if the original instance is already gone. Deletion protection blocks deletion, not renames, so this works on a prod instance.
A rename changes the instance's DNS endpoint, and propagation can take up to
~10 minutes after the instance reports available. Before moving on,
confirm the canonical endpoint resolves:
dig +short $(aws rds describe-db-instances \
--db-instance-identifier <stack>-postgres \
--query 'DBInstances[0].Endpoint.Address' --output text)
4. Reconcile Terraform and restart the service
Point Terraform state at the restored instance first. The provider tracks
the database by DbiResourceId, which followed the old instance through its
rename — without this step, an apply tries to rename <stack>-postgres-old
back over the restored instance and fails. The state file lives at
infra/terraform.<stack>.<stage>.tfstate (unless TF_STATE_PATH was
overridden):
terraform -chdir=infra state rm \
-state=terraform.<stack>.<stage>.tfstate aws_db_instance.postgres
terraform -chdir=infra import \
-state=terraform.<stack>.<stage>.tfstate \
-var stack_name=<stack> -var deployment_stage=<stage> \
-var aws_region=<region> -var server_image_uri=unused \
aws_db_instance.postgres <stack>-postgres
(server_image_uri is the only variable without a default; import never
reads it, but Terraform requires a value to evaluate the configuration.)
Then verify the plan shows only in-place updates to the database (no destroy, no rename) before applying:
./scripts/aws-deploy.sh plan
./scripts/aws-deploy.sh deploy --skip-build
The apply re-registers the RDS Proxy target, restores deletion protection
and other instance settings Terraform manages, refreshes the DATABASE_URL
secret, and returns the ECS service and autoscaling to their configured
capacity. On a prod stack, confirm the proxy target is healthy:
aws rds describe-db-proxy-targets --db-proxy-name <stack>-postgres-proxy
5. Validate
./scripts/aws-deploy.sh status
curl https://<host>/
curl https://<host>/pubkey
Then run the relevant SDK or dashboard smoke path from
PRODUCTION.md.
6. Clean up
Keep <stack>-postgres-old until the restored stack has been validated in
real use. Then snapshot and delete it:
aws rds modify-db-instance --db-instance-identifier <stack>-postgres-old \
--no-deletion-protection --apply-immediately
aws rds wait db-instance-available --db-instance-identifier <stack>-postgres-old
aws rds delete-db-instance --db-instance-identifier <stack>-postgres-old \
--final-db-snapshot-identifier <stack>-postgres-old-final
Full-stack recovery (after a destroy)
./scripts/aws-deploy.sh cleanup destroys everything Terraform manages —
not just the database but also the subnet group, security group, RDS Proxy,
ECS service, and the Terraform state entries for all of them. The final
snapshot (<stack>-postgres-final) survives, but automated backups are
deleted along with the instance, so point-in-time recovery is no longer
available. The stack-intact procedure cannot run against a
destroyed stack: its commands reference resources that no longer exist, and
a plain redeploy would try to create a database, not adopt a restored one.
Recover in two moves:
1. Recreate the stack with no running tasks
TF_VAR_server_desired_count=0 \
TF_VAR_server_autoscaling_max_capacity=0 \
./scripts/aws-deploy.sh deploy --skip-build
This recreates every prerequisite — subnet group, security group, proxy,
secrets, ECS service — plus a fresh, empty database at the canonical
identifier, all tracked in Terraform state again. --skip-build reuses the
ECR image, which survives cleanup (ECR is not Terraform-managed); drop the
flag if the image is gone too. The two overrides pin both the desired count
and the autoscaling ceiling to zero tasks (desired count alone is not
enough — in prod the ceiling would default to 6 and target tracking could
start tasks against the empty database before the swap).
2. Restore from the final snapshot
Run stack-intact step 2 using the
snapshot path with --db-snapshot-identifier <stack>-postgres-final.
3. Reconcile the master password
Unless the stack pins postgres_password, the master password is a
Terraform-generated random value per state file — the redeploy in step 1
minted a new one, while the snapshot carries the old one. Every credential
Terraform writes (the DATABASE_URL secret, the proxy auth secret) uses the
new value, so the restored instance must be reset to match before the server
can log in:
NEW_PASSWORD=$(aws secretsmanager get-secret-value \
--secret-id <stack>/server/database-url \
--query SecretString --output text | sed -E 's|^postgres://[^:]+:([^@]+)@.*|\1|')
aws rds modify-db-instance \
--db-instance-identifier <stack>-postgres-restored \
--master-user-password "$NEW_PASSWORD" --apply-immediately
aws rds wait db-instance-available \
--db-instance-identifier <stack>-postgres-restored
The generated password is alphanumeric-only, so extracting it from the URL
needs no decoding. If the stack pins postgres_password (same value across
deploys), the passwords already match and this step is a no-op.
4. Swap and finish
Continue the stack-intact procedure from
step 3. Step 1 (stop writes) is already satisfied, and the fresh empty
instance plays the role of the original: it gets renamed to
<stack>-postgres-old and eventually deleted in cleanup. Run the step 4
re-apply without the two TF_VAR_server_* overrides so the service and
autoscaling return to their configured capacity.
After the restore: Guardian-level reconciliation
A point-in-time restore rewinds Guardian's stored state. Infrastructure-level loss is bounded by WAL shipping (~5 minutes), but any delta that canonicalized on-chain after the restore point cannot be regenerated by the guardian: guarded accounts are private on Miden, so the chain holds only a commitment hash and the full state lives on client devices.
Accounts whose on-chain state advanced past the restored database state fail
state-commitment verification until a client device holding the newer state
re-syncs it — the "Guardian database corruption" row in the
CONCEPTS.md failure table. Accounts
onboarded after the restore point disappear from the guardian entirely:
requests fail with account_not_found (HTTP 404, gRPC NOT_FOUND) rather than
failing verification. A device holding the account re-onboards via
/configure, which re-registers it with the state that device currently holds;
other cosigner devices then sync from the guardian. Accounts with no
post-restore-point activity are unaffected.
Logical backups (optional)
For copies that live outside the AWS account (offline archives, cross-region
without a replica), take periodic pg_dump backups. Two constraints shape
the procedure:
- The instance is not publicly accessible, so
pg_dumpmust run from inside the VPC. - The Guardian runtime image ships
libpq5only — nopg_dump(Dockerfileserver-runnerstage) — so an ECS Exec session into the server container needs the PostgreSQL client installed first. The container runs as root and the install is ephemeral (gone when ECS replaces the task), which is fine for a one-off dump.
TASK_ARN=$(aws ecs list-tasks --cluster <stack>-cluster \
--service-name <stack>-server --query 'taskArns[0]' --output text)
aws ecs execute-command --cluster <stack>-cluster --task "$TASK_ARN" \
--container <stack>-server --interactive --command /bin/bash
The client's major version must be at least the server's — pg_dump
refuses servers newer than itself, and Debian bookworm's default
postgresql-client is 15 while the stack's engine is whatever AWS defaulted
to at creation (rds_engine_version is unpinned). Check the engine first:
aws rds describe-db-instances --db-instance-identifier <stack>-postgres \
--query 'DBInstances[0].EngineVersion' --output text
Inside the session, install the matching client major from the PGDG
repository (substitute the major version from the check above), then dump
using the DATABASE_URL already present in the container environment:
apt-get update && apt-get install -y postgresql-common awscli
/usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
apt-get install -y postgresql-client-<major>
pg_dump "$DATABASE_URL" --format=custom --file=/tmp/guardian-$(date +%F).dump
To get the dump out, paste short-lived operator credentials into the session (the task role deliberately has no S3 grant) and upload:
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=...
aws s3 cp /tmp/guardian-*.dump s3://<your-backup-bucket>/
Alternatively, run a one-off Fargate task from the public postgres:<major>
image, attached to the server task's security group, with the same
DATABASE_URL secret — avoids touching a serving container at the cost of
registering a task definition.
If storage encryption is enabled, remember the dump is ciphertext without the key secret.