DynamoDB gives you two backup features out of the box, and both are good. Point-in-time recovery rewinds a table to any second in the last 35 days. On-demand backups snapshot a table indefinitely, with no performance hit.
They share one weakness, and it's the one that matters: both live in the AWS account you'd be recovering from. Delete the account, lose the credentials, or hand admin to the wrong person, and the backups go with the table. PITR also can't help you past its 35-day ceiling — if a corruption is discovered on day 40, that data is gone.
This guide keeps PITR (it's the fastest recovery you have for the common case) and adds the copy that survives losing AWS entirely:
- Capture the table schema, not just the rows
- Export the data
- Ship it off-provider: encrypted, deduplicated, versioned
- Prove it restores
- Retention and scheduling
1. Capture the schema first
This is the step that turns a working restore into a broken one, and it's almost always skipped.
An export or a scan gives you items. It does not give you the key schema, secondary indexes, TTL configuration, stream settings, or billing mode. Restore a million items into a table with the wrong partition key and every access pattern in your application breaks. Restore without the GSIs and half your queries return nothing — silently, with no error.
So capture the shape of the table alongside the data:
mkdir -p ./ddb-dumps
aws dynamodb describe-table --table-name app_table \
> ./ddb-dumps/app_table.schema.json
# TTL is a separate API call and is not in describe-table:
aws dynamodb describe-time-to-live --table-name app_table \
> ./ddb-dumps/app_table.ttl.json
describe-table covers KeySchema, AttributeDefinitions, GlobalSecondaryIndexes, LocalSecondaryIndexes, billing mode, and stream configuration. Together with the TTL call, that's enough to rebuild the table exactly.
These files are tiny and change almost never — which means deduplication makes them effectively free to keep on every single snapshot.
2. Export the data
Which method you want depends on table size.
For tables under a few GB — scan to JSON. Simple, no S3 in the loop:
aws dynamodb scan \
--table-name app_table \
--output json \
> ./ddb-dumps/app_table.items.json
Three caveats worth respecting:
- Scans consume read capacity. On a provisioned table, a full scan can starve production traffic. Throttle it with
--max-itemsplus pagination, or run it against an on-demand table, or schedule it for a quiet window. - Scans are eventually consistent by default. For a backup that's usually fine — you're capturing "roughly this moment." Add
--consistent-readif you need a tighter guarantee, and expect it to cost twice the RCUs. - A scan is not atomic. Items written during the scan may or may not appear. If you need a genuinely consistent point-in-time image, use the export method below, which is PITR-backed.
For anything larger — export to S3, then sync the export down. This is PITR-backed, so it's a true point-in-time image and consumes no table capacity at all:
aws dynamodb export-table-to-point-in-time \
--table-arn arn:aws:dynamodb:us-east-1:123456789012:table/app_table \
--s3-bucket your-export-bucket \
--export-format DYNAMODB_JSON
# then, once the export completes:
aws s3 sync s3://your-export-bucket/AWSDynamoDB ./ddb-dumps/export --delete
Export requires PITR to be enabled on the table — turn it on first if it isn't:
aws dynamodb update-continuous-backups \
--table-name app_table \
--point-in-time-recovery-specification PointInTimeRecoveryEnabled=true
Keep the output paths stable. Overwrite the same files on every run. That's what lets deduplicated, incremental uploads work in the next step: a table that changed 2% overnight ships roughly 2% of the bytes.
3. Ship it off-provider
A dump sitting in the same account as the table dies with the account. Push it to storage with credentials AWS doesn't issue.
Sign in at Backup Data, claim the free 5 GB workspace, mint an API key scoped backup:write, backup:read, snapshots:read, then:
npm install @lighthouse-web3/baas-js-sdk
export LH_API_KEY="lh_xxxxxxxxxxxxxxxxxxxxxxxx"
export LH_WORKSPACE_ID="your-workspace-uuid"
import { BackupClient } from "@lighthouse-web3/baas-js-sdk";
const client = new BackupClient({
apiKey: process.env.LH_API_KEY,
workspaceId: process.env.LH_WORKSPACE_ID,
});
const snapshot = await client.backup(["./ddb-dumps"], {
description: "nightly dynamodb export",
tags: { type: "dynamodb", table: "app_table", env: "prod" },
});
console.log(`snapshotId=${snapshot.snapshotId} totalSize=${snapshot.totalSize}`);
(There's a Go SDK with the same surface.)
Each run is an immutable point-in-time snapshot, and content-defined chunking means near-identical nightly exports cost you the delta rather than the full table.
DynamoDB tables usually hold user data, so encrypt client-side. AWS encrypts the table at rest with keys AWS holds; that protection ends the moment the export leaves:
import { generateKeyfile } from "@lighthouse-web3/baas-js-sdk";
// once:
generateKeyfile("/secure/lh.keyfile", process.env.LH_KEYFILE_PASSPHRASE);
// then on every backup:
await client.backup(["./ddb-dumps"], {
description: "nightly-encrypted",
encryption: {
keyfilePath: "/secure/lh.keyfile",
passphrase: process.env.LH_KEYFILE_PASSPHRASE,
},
});
AES-GCM on your machine, ciphertext on the server. You hold the keys — lose the keyfile and passphrase and the data is unrecoverable, with no server-side reset. Store it outside the AWS account you're protecting against.
4. Prove it restores
Restoring DynamoDB is meaningfully harder than restoring a SQL dump, which is exactly why you should find out how it goes before you need it.
Restore the files:
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const target = mkdtempSync(join(tmpdir(), "ddb-restore-drill-"));
await client.restore(snapshot.snapshotId, target, {
onProgress: (e) => console.log(`[${e.phase}] ${e.current}/${e.total}`),
});
Every restore verifies chunk integrity against expected checksums before reassembly.
Rebuild the table from the captured schema, into a new name so you never touch production:
# Reuse the exact key schema and indexes you saved in step 1:
aws dynamodb create-table \
--table-name app_table_restore \
--cli-input-json file://<(jq '.Table | {
AttributeDefinitions, KeySchema, GlobalSecondaryIndexes,
BillingMode: "PAY_PER_REQUEST"
} | del(.GlobalSecondaryIndexes[]?.IndexStatus,
.GlobalSecondaryIndexes[]?.IndexSizeBytes,
.GlobalSecondaryIndexes[]?.ItemCount,
.GlobalSecondaryIndexes[]?.IndexArn,
.GlobalSecondaryIndexes[]?.ProvisionedThroughput)' \
"$TARGET/ddb-dumps/app_table.schema.json")
describe-table output includes read-only fields that create-table rejects, which is what the del(...) is stripping. Getting this jq right during a calm Tuesday afternoon is much nicer than deriving it during an outage — this is the single best reason to run the drill.
Load the items back. batch-write-item takes 25 items per request, so chunk them:
import { readFileSync } from "node:fs";
import { DynamoDBClient, BatchWriteItemCommand } from "@aws-sdk/client-dynamodb";
const ddb = new DynamoDBClient({});
const { Items } = JSON.parse(readFileSync(`${target}/ddb-dumps/app_table.items.json`, "utf8"));
for (let i = 0; i < Items.length; i += 25) {
const batch = Items.slice(i, i + 25).map((Item) => ({ PutRequest: { Item } }));
const res = await ddb.send(new BatchWriteItemCommand({
RequestItems: { app_table_restore: batch },
}));
// Throttling returns items instead of failing. Ignoring this loses data silently.
if (res.UnprocessedItems?.app_table_restore?.length) {
console.warn("unprocessed, retry these:", res.UnprocessedItems.app_table_restore.length);
}
}
That UnprocessedItems check is not optional. DynamoDB signals throttling by returning the items it declined rather than throwing, so a naive loop reports a clean restore while quietly dropping rows. Retry them with exponential backoff.
For large exports, aws dynamodb import-table reads DynamoDB JSON straight from S3 and is far faster — note it can only create a new table, never load into an existing one.
Then verify and time it:
aws dynamodb scan --table-name app_table_restore --select COUNT
Compare against production's item count, spot-check a few known keys, and record the wall-clock time. That number is your real RTO.
The drill checklist:
- ✅ Restore into a fresh table name, never over production
- ✅ Confirm GSIs exist and return results, not just that items loaded
- ✅ Check
UnprocessedItemson every batch write - ✅ Re-apply TTL config from the saved file — it doesn't come back on its own
- ✅ Compare item counts and spot-check real keys
- ✅ Record wall-clock time, keep a log across drills
- ✅ Delete the restore table when done, so it stops costing money
5. Retention and scheduling
const before = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
// sourceId identifies the machine and path a backup came from; every snapshot
// carries it. Retention is currently applied across the whole workspace, so
// keepLatest counts every snapshot in it, not just this source's.
const policy = { sourceId: snapshot.sourceId, keepLatest: 14, before };
const preview = await client.pruneSnapshots({ ...policy, dryRun: true });
console.log("would delete:", preview.snapshotIds);
await client.pruneSnapshots({ ...policy, dryRun: false });
# /etc/cron.d/dynamodb-backup (nightly at 03:00)
0 3 * * * backup /opt/backup/ddb-backup.sh >> /var/log/ddb-backup.log 2>&1
Monthly, run the drill. It's the only part of this that actually tests anything.
The pipeline, complete
| Step | Command / call | Proves |
|---|---|---|
| Schema | describe-table, describe-time-to-live | You can rebuild the table's shape |
| Export | export-table-to-point-in-time or scan | You have the items |
| Off-provider | client.backup([...]) | It survives losing the AWS account |
| Restore drill | create-table → batch-write-item | It actually restores |
| Retention | client.pruneSnapshots({...}) | Storage stays bounded |
| Schedule | cron + monthly drill | It happens without you |
Keep PITR switched on. For "someone deleted the wrong items an hour ago" it's the fastest recovery in existence, and nothing here replaces it. What it can't do is outlive the account — and that's the specific hole this pipeline fills.
Start with the free tier: backupdata.io has 5 GB free, no card, and the 10-minute quickstart gets you to a first verified snapshot today.