A backup you've never restored is not a backup — it's a hope. I've said that in both previous posts, so this one pays it off. Here's the exact mongorestore workflow I run to pull an archive back out of S3, restore it into a scratch database, verify it, and only then let it anywhere near production.
If you're restoring somewhere with no AWS credentials configured, run aws configure first, or attach an IAM role if you're on EC2.
Heads up on IAM permissions. The scoped policy from the backup post only grants s3:PutObject and s3:ListBucket — that's write-only: enough to upload a backup, but not to download one. Restoring also needs s3:GetObject, so add it — scoped to the backup bucket/prefix, e.g. s3:GetObject on arn:aws:s3:::your-bucket-name/* (or an equivalently scoped role). Resist reaching for AmazonS3FullAccess: it grants s3:* over every bucket and object, including deletes, which a one-way restore never needs. Without s3:GetObject, aws s3 cp fails with An error occurred (AccessDenied).
Sanity check the download. A truncated transfer produces a file that mongorestore chokes on with Failed: archive parser: ... unexpected EOF. Compare sizes before you trust it:
aws s3 ls s3://your-bucket-name/backup-2026-08-24_02-00-00.gzls -l ~/restore/backup.gz
This is the rule I never break: restore into a throwaway namespace, not over live data. The backup was a single database (mydatabase), so remap it on the way in with --nsFrom / --nsTo:
If you're rebuilding a dead server from scratch and there's no live data to protect, skip the remap and let it land on its real name. Add --drop so existing collections are replaced instead of merged:
Without --drop, mongorestoremerges — and you'll hit E11000 duplicate key error on every _id that already exists. That error almost always means "you forgot --drop."
If the counts are sane and a sample document looks right, the backup is good. If not, you just found out on a scratch database instead of in production — which is the entire point.
Point the app at the restored database — change the connection string's database name to mydatabase_restore and restart. Fastest, zero data movement, and the original stays around as evidence.
Or swap names if you'd rather keep mydatabase as the canonical name:
Stop the app before you swap. These two renames aren't atomic together. If the app is still serving traffic, a write landing between them can recreate mydatabase.orders — and the second rename then fails, because dropTarget: false won't overwrite it, leaving you half-swapped. Take the app offline first, and confirm each runCommand returns { ok: 1 } before moving on.
mongosh --quiet --eval ' db.getSiblingDB("admin").runCommand({ renameCollection: "mydatabase.orders", to: "mydatabase_old.orders", dropTarget: false }); db.getSiblingDB("admin").runCommand({ renameCollection: "mydatabase_restore.orders", to: "mydatabase.orders", dropTarget: false });'
That's two renames per collection — move the live one aside, then promote the restored one into its place — and renameCollection needs the admin database. For a whole-database swap that's a lot of fiddly, error-prone steps, so I usually just repoint the app instead — it's fully reversible and there's nothing to undo if I change my mind.
The only way "restore from backup" stops being scary is repetition. Here's a drill script that downloads the latest archive, restores it to a scratch database, prints the collection counts, and cleans up:
nano ~/restore-drill.sh
#!/bin/bash# === Quarterly MongoDB restore drill ===# Same `snap run aws-cli.aws` approach as the backup post — see there for the cron/PATH rationale.set -eS3_BUCKET="s3://your-bucket-name"WORK_DIR="$HOME/restore-drill"DRILL_DB="mydatabase_drill"LOG_FILE="$HOME/restore-drill.log"# Always clean up the drill DB and local archive, even if a step failscleanup() { mongosh --quiet --eval "db.getSiblingDB('$DRILL_DB').dropDatabase();" 2>/dev/null || true rm -f "$WORK_DIR/drill.gz"}trap cleanup EXITmkdir -p "$WORK_DIR"echo "Starting restore drill: $(date)" >> "$LOG_FILE"# Grab the most recent archive by KEY name. `aws s3 ls` lines start with the# object's modification date, so sorting the raw output would let a re-uploaded# old archive masquerade as "latest". Pull out the key column ($4), keep only# backup archives, then sort by the date-stamped filename — and bail if none match.LATEST=$(snap run aws-cli.aws s3 ls "$S3_BUCKET/" \ | awk '{print $4}' \ | grep -E '^backup-.*\.gz$' \ | sort \ | tail -n 1)if [ -z "$LATEST" ]; then echo "No backup archives found in $S3_BUCKET — aborting drill." >> "$LOG_FILE" exit 1fiecho "Latest archive: $LATEST" >> "$LOG_FILE"snap run aws-cli.aws s3 cp "$S3_BUCKET/$LATEST" "$WORK_DIR/drill.gz" >> "$LOG_FILE" 2>&1# Restore into an isolated drill database, replacing any previous drill runmongorestore \ --uri="mongodb://localhost:27017" \ --gzip \ --archive="$WORK_DIR/drill.gz" \ --nsFrom="mydatabase.*" \ --nsTo="$DRILL_DB.*" \ --drop >> "$LOG_FILE" 2>&1# Prove it: print document counts per collectionmongosh --quiet --eval " const scratch = db.getSiblingDB('$DRILL_DB'); scratch.getCollectionNames().forEach(c => { print(c + ': ' + scratch.getCollection(c).countDocuments()); });" >> "$LOG_FILE" 2>&1# Assert a known collection actually came back — mongorestore can exit 0 with zero docsEXPECTED_COLLECTION="orders"COUNT=$(mongosh --quiet --eval "db.getSiblingDB('$DRILL_DB').getCollection('$EXPECTED_COLLECTION').countDocuments()")if ! [[ "$COUNT" =~ ^[0-9]+$ ]] || [ "$COUNT" -eq 0 ]; then echo "DRILL FAILED: $EXPECTED_COLLECTION is missing or empty (count=$COUNT)" >> "$LOG_FILE" exit 1fiecho "Drill verified: $EXPECTED_COLLECTION has $COUNT documents" >> "$LOG_FILE"# Drill DB and local archive are removed by the EXIT trap, on success or failureecho "Restore drill completed: $(date)" >> "$LOG_FILE"
Make it executable, run it once by hand, then schedule it for the first of every quarter:
Failed: archive parser: ... unexpected EOF
The archive is truncated. Almost always a partial S3 download — re-run aws s3 cp and compare byte counts (Step 2).
E11000 duplicate key error collection: mydatabase.orders index: _id_
You restored over existing data without --drop, so mongorestore tried to merge and collided on _id. Add --drop, or restore into a scratch namespace.
Failed: ... requires mongorestore version >= X
Version skew. Use the same MongoDB Database Tools release for mongodump and mongorestore — the Tools are versioned independently of the database server. A newer Tools release will generally read an older archive, but match them when you can and validate against the target server version. Check with mongorestore --version.
Failed: error connecting to db server: no reachable servers
Wrong --uri, MongoDB isn't running, or auth is required. Add credentials to the URI: mongodb://user:pass@localhost:27017/?authSource=admin.
Can I restore a --gzip --archive dump without decompressing it first?
Yes — and you should. mongorestore --gzip --archive=file.gz reads the compressed archive directly, so there's no reason to unpack it first. If you dogunzip it, that's fine too: you get a valid uncompressed archive that restores with mongorestore --archive=file (just drop the --gzip flag). The only thing that actually breaks is a flag mismatch — passing --gzip on an already-decompressed archive, or omitting it on a compressed one.
How do I restore into a different MongoDB server (say, staging)?
Point --uri at the other host: --uri="mongodb://staging-host:27017". Everything else is identical. This is the safest place to run drills if you can spare a box.
Do I need to stop the application during a restore?
For a scratch-database restore, no — production is untouched. For an in-place --drop restore over live collections, yes: take the app offline first, or you'll serve half-restored data.
Restore is painfully slow — can I speed it up?
Add --numInsertionWorkersPerCollection=4 to parallelise inserts, and restore data first without indexes using --noIndexRestore, then build indexes afterward. On a large restore that ordering alone can halve the wall-clock time.
What if I only have the S3 archive and a brand-new empty server?
That's the --drop path in Step 3 with no remap. Install the matching MongoDB version, aws s3 cp the archive down, mongorestore --gzip --archive=$HOME/restore/backup.gz --drop, verify, done. This is exactly what the quarterly drill rehearses so you're not learning it during an outage.