SN

Restore a MongoDB Backup from S3 Before You Need To

Learn how to restore a MongoDB backup from S3 with mongorestore — the exact workflow I run so an untested backup never becomes a very bad day.

9 min read

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.

The setup this restores from is the one I built in the automated MongoDB → S3 backup post, monitored by the Amazon SES failure alerts post. If your backups are mongodump --archive --gzip files sitting in S3, this picks up exactly where those left off.

Goal

  • Download a specific backup archive from S3
  • Restore it into a scratch database first — never straight over production
  • Verify the restore actually contains your data
  • Promote it, or throw it away, on your terms
  • Automate a quarterly restore drill so this is boring, not terrifying

Architecture

AWS S3 (backup-YYYY-MM-DD.gz)
   ↓  aws s3 cp
Local archive
   ↓  mongorestore --gzip --archive
Scratch database (mydatabase_restore)
   ↓  verify counts + sample docs
Promote to production (or drop it)

1. Find the archive you want in S3

List what's actually in the bucket before assuming a filename:

aws s3 ls s3://your-bucket-name/ --human-readable

You'll get the dated archives the backup script uploaded:

2026-08-22 02:00:11    1.9 GiB backup-2026-08-22_02-00-00.gz
2026-08-23 02:00:09    1.9 GiB backup-2026-08-23_02-00-00.gz
2026-08-24 02:00:12    1.9 GiB backup-2026-08-24_02-00-00.gz

Pick one — usually the most recent that predates whatever went wrong.

2. Download it to the server

mkdir -p ~/restore/
aws s3 cp s3://your-bucket-name/backup-2026-08-24_02-00-00.gz ~/restore/backup.gz

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.gz
ls -l ~/restore/backup.gz

The byte counts must match.

3. Restore into a scratch database first

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:

mongorestore \
  --uri="mongodb://localhost:27017" \
  --gzip \
  --archive=$HOME/restore/backup.gz \
  --nsFrom="mydatabase.*" \
  --nsTo="mydatabase_restore.*"

Now you have mydatabase_restore sitting next to production, untouched, ready to inspect. If the restore is garbage, you've lost nothing.

Restoring straight over the original (only when you mean it)

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:

mongorestore \
  --uri="mongodb://localhost:27017" \
  --gzip \
  --archive=$HOME/restore/backup.gz \
  --drop

Without --drop, mongorestore merges — and you'll hit E11000 duplicate key error on every _id that already exists. That error almost always means "you forgot --drop."

4. Restore just one collection

Full restores are slow when you only nuked one collection. Scope it with --nsInclude:

mongorestore \
  --uri="mongodb://localhost:27017" \
  --gzip \
  --archive=$HOME/restore/backup.gz \
  --nsInclude="mydatabase.orders" \
  --nsFrom="mydatabase.*" \
  --nsTo="mydatabase_restore.*"

Only orders comes back, into the scratch database. Restore, copy the rows you need, move on.

5. Verify the restore actually worked

mongorestore exiting 0 means the archive parsed — not that your data is intact. Check it yourself:

mongosh --quiet --eval '
  const scratch = db.getSiblingDB("mydatabase_restore");
  scratch.getCollectionNames().forEach(c => {
    print(c + ": " + scratch.getCollection(c).countDocuments());
  });
'

Compare those counts against production (or against what you remember before the incident). Then eyeball a real document:

mongosh --quiet --eval '
  printjson(db.getSiblingDB("mydatabase_restore").orders.findOne());
'

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.

6. Promote it to production

Once verified, you've got two clean options.

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.

7. Automate a quarterly restore drill

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 -e
 
S3_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 fails
cleanup() {
  mongosh --quiet --eval "db.getSiblingDB('$DRILL_DB').dropDatabase();" 2>/dev/null || true
  rm -f "$WORK_DIR/drill.gz"
}
trap cleanup EXIT
 
mkdir -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 1
fi
 
echo "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 run
mongorestore \
  --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 collection
mongosh --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 docs
EXPECTED_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 1
fi
echo "Drill verified: $EXPECTED_COLLECTION has $COUNT documents" >> "$LOG_FILE"
 
# Drill DB and local archive are removed by the EXIT trap, on success or failure
echo "Restore drill completed: $(date)" >> "$LOG_FILE"

Make it executable, run it once by hand, then schedule it for the first of every quarter:

chmod +x ~/restore-drill.sh
~/restore-drill.sh
0 3 1 1,4,7,10 * $HOME/restore-drill.sh >> $HOME/restore-drill.log 2>&1

Wire the same SES alert pattern into this drill and you'll get an email the day a backup can't be restored — long before you actually need it.

Common errors and what they really mean

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.

FAQ

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 do gunzip 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.

The series, end to end

You now have the full loop:

  1. Automated MongoDB backups to S3 — the dump and upload.
  2. SES failure alerts — you hear about it the moment a backup breaks.
  3. This post — you can actually get your data back, and you've proven it on a schedule.

A backup you've tested is the only kind that counts. Run the drill.