# Backups you've never restored aren't backups. A restore drill, timed.

Every AWS data store has backups turned on by default or by a checkbox. Aurora keeps automated backups. DynamoDB has point-in-time recovery. S3 has versioning. The console shows green ticks, the compliance questionnaire gets a "yes", and nobody has ever actually restored anything, so nobody knows what a restore takes, whether it works, or what breaks when the restored copy has a different name.

We run a restore drill every quarter, into a separate account, with a stopwatch. This is the drill from last quarter: what we restored, how long each step took, the four things that failed, and what the numbers say about our real recovery objectives, as opposed to the ones on the slide.

## What's backed up, and how

The platform's state lives in three places: an Aurora Serverless v2 cluster, six DynamoDB tables, and two S3 buckets. The backups, all defined in CDK:

| Store | Mechanism | Retention | Where the copy lives |
|---|---|---|---|
| Aurora | Automated continuous backup, point-in-time | 35 days | Same account, plus a daily snapshot copied to the backup account via AWS Backup |
| DynamoDB | Point-in-time recovery, continuous | 35 days | Same account, plus daily AWS Backup to a cross-account vault |
| S3 | Versioning + lifecycle | Noncurrent versions kept 90 days | Same bucket, plus replication to the backup account |
| Secrets, parameters | AWS Backup doesn't cover these | n/a | Rebuilt from the [CDK definitions and a checklist](/en/blog/secrets-in-cdk-secrets-manager-ssm-or-nothing-in-the-template) |

The cross-account copy is the part people skip. A backup in the same account as production protects against a bad migration or a fat-fingered delete. It does not protect against the account itself being compromised, or against a `cdk destroy` with the wrong context [deleting things with RETAIN off](/en/blog/cloudformation-deleted-our-app-runner-services). AWS Backup with a vault in a separate account, with a vault lock, is what makes the copy survive the worst day. It costs about $6 a month at our size.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Backup topology. In the production account: Aurora with 35-day point-in-time recovery, DynamoDB with PITR, S3 with versioning. AWS Backup copies daily snapshots to a vault in a separate backup account with a vault lock; S3 replicates to the same account. The quarterly restore drill restores from the backup account into the staging account, never into production.">
<defs><marker id="arrB" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="20" y="30" width="270" height="190" rx="14" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="155" y="54" text-anchor="middle" fill="#ff6b8a" font-weight="700">production account</text>
<rect x="40" y="70" width="230" height="36" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="155" y="93" text-anchor="middle" fill="#f1f3ff" font-size="11">Aurora · PITR 35 days</text>
<rect x="40" y="114" width="230" height="36" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="155" y="137" text-anchor="middle" fill="#f1f3ff" font-size="11">DynamoDB × 6 · PITR 35 days</text>
<rect x="40" y="158" width="230" height="36" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="155" y="181" text-anchor="middle" fill="#f1f3ff" font-size="11">S3 × 2 · versioning 90 days</text>
<line x1="292" y1="125" x2="328" y2="125" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrB)"/><text x="310" y="112" text-anchor="middle" fill="#9aa3c7" font-size="10">daily</text>
<rect x="330" y="30" width="250" height="190" rx="14" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="455" y="54" text-anchor="middle" fill="#ffd166" font-weight="700">backup account</text>
<rect x="350" y="70" width="210" height="60" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="455" y="92" text-anchor="middle" fill="#f1f3ff" font-size="11">AWS Backup vault</text><text x="455" y="110" text-anchor="middle" fill="#9aa3c7" font-size="10">vault lock · nobody can delete</text>
<rect x="350" y="140" width="210" height="54" rx="8" fill="#0d1120" stroke="#2a3150"/><text x="455" y="162" text-anchor="middle" fill="#f1f3ff" font-size="11">S3 replica buckets</text><text x="455" y="180" text-anchor="middle" fill="#9aa3c7" font-size="10">separate KMS key</text>
<line x1="582" y1="125" x2="618" y2="125" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrB)"/><text x="600" y="112" text-anchor="middle" fill="#9aa3c7" font-size="10">quarterly</text>
<rect x="620" y="30" width="260" height="190" rx="14" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="750" y="54" text-anchor="middle" fill="#4fffb0" font-weight="700">staging account · the drill</text>
<text x="750" y="86" text-anchor="middle" fill="#f1f3ff" font-size="11">restore every store from the vault</text>
<text x="750" y="106" text-anchor="middle" fill="#f1f3ff" font-size="11">point the staging app at the restored copies</text>
<text x="750" y="126" text-anchor="middle" fill="#f1f3ff" font-size="11">run the smoke tests · time every step</text>
<text x="750" y="156" text-anchor="middle" fill="#9aa3c7" font-size="11">never into production</text>
<text x="750" y="176" text-anchor="middle" fill="#9aa3c7" font-size="11">tear down when done · ~$4 per drill</text>
<text x="450" y="242" text-anchor="middle" fill="#9aa3c7">Same-account backups survive a bad migration. Cross-account backups survive a bad day.</text>
</g>
</svg>
</div>

## The drill, with the stopwatch

The scenario: production data as of 14:00 yesterday is corrupt; restore everything to 13:55 and bring the application up on the restored data. In staging, from the backup account, with the smoke-test suite as the definition of "up". One engineer, no help, following the runbook.

| Step | Time | Notes |
|---|---|---|
| Aurora: restore cluster to point in time from the vault copy | 22 min | New cluster, new endpoint. Serverless v2, 0.5 ACU |
| Aurora: run smoke query, check row counts against the metrics dashboard | 3 min | Matched |
| DynamoDB: restore six tables from the vault, in parallel | 31 min | The largest table, 18 GB, took 28 of those |
| DynamoDB: re-enable PITR and TTL on the restored tables | 4 min | Restore does not carry these over. Neither does it carry auto-scaling, tags or the stream |
| S3: nothing to restore for this scenario; versioning check only | 2 min | |
| Repoint the app: write the new cluster endpoint and table names to SSM | 5 min | Should have been 1 minute; see failure 3 |
| Restart App Runner services and Lambdas so they read the new parameters | 6 min | |
| Smoke tests green | 4 min | |
| **Total** | **77 min** | |

Seventy-seven minutes for a full restore to a point in time, by one person following instructions. That's the real recovery time objective, and it's the number that goes in the runbook, not the "under an hour" that was on the slide before the first drill.

## The four things that failed

The first drill, a year ago, took four hours and didn't finish. Every quarter since has found something. Last quarter's four:

**1. The KMS key policy didn't allow the staging account to decrypt the Aurora snapshot.** The vault copy is encrypted with a key in the backup account. Restoring into staging needs that key's policy to grant `kms:Decrypt` and `kms:CreateGrant` to the staging account's restore role. It granted them to production's. The restore failed with a permissions error that took twenty minutes to read correctly. Fixed in CDK: the key policy now lists every account that might restore.

**2. DynamoDB restore drops the table's settings.** A restored table has the data and the key schema and nothing else: no PITR, no TTL attribute, no auto-scaling, no tags, no stream. Our app relies on TTL to expire sessions and on the stream to feed the search index. Without a checklist step for re-enabling these, the restored app "worked" and silently stopped expiring sessions. It's now a step in the runbook and, better, a small script that applies the settings from the CDK definition to a named table.

**3. One Lambda had the table name hard-coded.** Eleven of twelve functions read their table names from SSM parameters, so repointing was a parameter write. One, the oldest, had the production table name as a literal in its environment. It kept happily writing to the *old* table during the drill, which in a real recovery would have meant writing new data into the corrupt store. Found because the smoke test for that function failed. Fixed by moving the name to SSM like the others, and by a CI check that greps environment blocks for anything matching a resource-name pattern.

**4. The restore role in staging couldn't `PassRole` to the Aurora service role.** A one-line IAM omission, discovered at minute 30, fixed at minute 35, and a reminder that the restore path has its own set of permissions that nothing exercises except the drill.

None of these would have been found by looking at the green ticks. All four would have turned a real incident from "bad afternoon" into "bad week".

<div class="article-figure">
<svg viewBox="0 0 900 220" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Timeline of the 77-minute restore drill. Aurora point-in-time restore 22 minutes, smoke query 3. DynamoDB six tables restored in parallel 31 minutes, settings re-applied 4. S3 check 2. Repoint via SSM 5, restart services 6, smoke tests 4. Four failure markers: KMS key policy, DynamoDB settings dropped, hard-coded table name, missing PassRole.">
<g font-family="Inter,system-ui,sans-serif" font-size="11">
<text x="20" y="22" fill="#f1f3ff" font-size="14" font-weight="700">The drill · 77 minutes · one engineer · staging account</text>
<line x1="60" y1="110" x2="880" y2="110" stroke="#2a3150" stroke-width="2"/>
<rect x="60" y="60" width="234" height="22" rx="4" fill="#7b8cff" opacity="0.8"/><text x="177" y="75" text-anchor="middle" fill="#0d1120" font-weight="700">Aurora PITR · 22 min</text>
<rect x="60" y="86" width="372" height="22" rx="4" fill="#4fffb0" opacity="0.8"/><text x="246" y="101" text-anchor="middle" fill="#0d1120" font-weight="700">DynamoDB × 6 · 31 min · in parallel</text>
<rect x="294" y="60" width="32" height="22" rx="4" fill="#7b8cff" opacity="0.5"/>
<rect x="432" y="86" width="43" height="22" rx="4" fill="#4fffb0" opacity="0.5"/><text x="453" y="101" text-anchor="middle" fill="#0d1120" font-size="9">settings</text>
<rect x="475" y="86" width="21" height="22" rx="4" fill="#9aa3c7" opacity="0.5"/>
<rect x="496" y="86" width="53" height="22" rx="4" fill="#ffd166" opacity="0.8"/><text x="522" y="101" text-anchor="middle" fill="#0d1120" font-size="9">repoint</text>
<rect x="549" y="86" width="64" height="22" rx="4" fill="#ffd166" opacity="0.6"/><text x="581" y="101" text-anchor="middle" fill="#0d1120" font-size="9">restart</text>
<rect x="613" y="86" width="43" height="22" rx="4" fill="#4fffb0"/><text x="634" y="101" text-anchor="middle" fill="#0d1120" font-size="9">smoke</text>
<text x="60" y="130" fill="#9aa3c7" font-size="10">0</text><text x="656" y="130" fill="#9aa3c7" font-size="10">77 min</text>
<circle cx="90" cy="150" r="5" fill="#ff6b8a"/><text x="100" y="154" fill="#ff6b8a">1 · KMS key policy · 20 min lost reading the error</text>
<circle cx="90" cy="172" r="5" fill="#ff6b8a"/><text x="100" y="176" fill="#ff6b8a">2 · DynamoDB restore drops PITR, TTL, stream, auto-scaling, tags</text>
<circle cx="90" cy="194" r="5" fill="#ff6b8a"/><text x="100" y="198" fill="#ff6b8a">3 · one Lambda with a hard-coded table name kept writing to the old table</text>
<circle cx="500" cy="150" r="5" fill="#ff6b8a"/><text x="510" y="154" fill="#ff6b8a">4 · restore role missing iam:PassRole</text>
<text x="500" y="176" fill="#9aa3c7">the first drill, a year ago: four hours, did not finish</text>
<text x="500" y="198" fill="#9aa3c7">every quarter since has found at least one of these</text>
</g>
</svg>
</div>

## The numbers that came out of it

Recovery point objective: five minutes for Aurora and DynamoDB, because point-in-time recovery is continuous; S3 is immediate through versioning. That part was true before the drill.

Recovery time objective: 77 minutes for a full restore, about 30 for a single DynamoDB table, about 25 for Aurora alone. Those numbers were fiction before the drill and they're measurements now. They're in the runbook and in the status-page template, so the message during a real recovery says "restore in progress, expected complete by 15:20" instead of "soon".

Cost of the drill: about $4 in restored resources for the afternoon, plus one engineer's afternoon. Cost of not doing it: unknown until the day it matters, which is the wrong day to learn.

## The runbook, condensed

- Declare the target point in time. Write it down before touching anything.
- Restore into the isolated account. Never restore over production; restore next to it and repoint.
- Aurora: point-in-time restore from the vault copy; verify row counts against a known metric.
- DynamoDB: restore all tables in parallel; then run the settings script for PITR, TTL, streams, auto-scaling, tags.
- S3: identify the object versions at the target time; copy forward if needed.
- Repoint: the application reads every resource name from SSM. Write the new names. Nothing is hard-coded, and CI enforces that.
- Restart everything that caches parameters.
- Smoke tests. Not "it loads": the suite that exercises checkout, login and the search index.
- Time every step. Update the RTO in the runbook if it moved.
- Tear down, and write the one-page note with what failed.

Quarterly. On the calendar. With a different engineer each time, because the point is that the runbook works for the person who didn't write it.

If your backups have green ticks and no restore history, [we'll run the first drill with you](/contact). Budget an afternoon and expect to find something.
