Recover an accidentally deleted dataset
1 · Prerequisites
Confirm every item is in place before any state change.
- Administrative access on the host that still holds the affected path, and on whatever runs the schedule — the two are frequently not the same machine, and the schedule is the one that matters first.
- A written inventory of every job that reads this dataset and writes it somewhere else: mirrors, offsite sync, storage replication, repository prune, lifecycle expiry, and the configuration-management or GitOps loop that would re-enable any of them.
- Read credentials for each repository and bucket that might hold a recovery point, distinct from the production identity that performed the deletion.
- Free capacity on a separate path for a full restore beside the original, sized from the last known dataset size rather than from what remains.
- Agreement, in advance, that stopping a scheduled synchronisation during an incident does not need a change ticket. If that agreement does not exist, obtaining it is the first escalation, not the first step.
- A named data owner who can state whether a recovered copy is the right copy, and who is reachable now.
- A place to record timings, decisions and identifiers as you go, because the recovery point you choose has to be defensible afterwards.
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · Stop every scheduled job that could copy the current state onward, before you read a single log. This is the one pre-check that is also an action, and it is deliberately first. In the measured capture a single scheduled
rsync -a --deleterun removed the operator's deleted file from the mirror as well, with exit code 0: Elapsed time between the mistake and the loss of the only other copy: one scheduled interval. Nobody had to make a second mistake. - · Confirm the jobs are stopped rather than merely disabled at next boot. A stopped timer with an unmasked unit is one
systemctl start, one configuration-management run or one reboot away from firing. Check the system timers, root and service crontabs, the backup platform's own scheduler, and the storage array's replication schedule separately; they fail independently and each one is sufficient on its own. - · Confirm at least one copy retains history rather than reproducing current state. A mirror, a storage replica and a continuous sync all converge on whatever the source looks like now, so after the deletion they hold the deletion. If every copy in the inventory is of that kind, stop and escalate before spending time on a recovery that has no source.
- · Confirm free capacity for a full restore beside the original. The staging restore is what makes every later step reversible; if it does not fit, that constraint changes the plan and needs to be known now rather than at 80% of a transfer.
- · Confirm the credentials you will use are not the identity that performed the deletion, and are read-only wherever the platform allows it. On object storage the measured
mc rmfrom areadwriteidentity exited 0, printedCreated delete marker, and left the object out of every ordinary listing while its bytes stayed stored. An investigation run with the same rights can quietly add more markers. - · Confirm the deletion is confined to this dataset. Look at the siblings — adjacent directories, other prefixes in the bucket, other volumes on the host. A deletion that stops neatly at one path is usually a mistake; one accompanied by renames, new extensions or unreadable files elsewhere is a different incident with a different first responder.
- · Record the start time and the last known good state. The capture's 09:00 source tree recorded
orders.csv md5: 9eb4e2ad8e08e1dcaaf87ababab964b0before anything was deleted. A digest taken before an incident is worth more than any amount of reconstruction afterwards, and noting whether one exists takes ten seconds.
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Stop the scheduled synchronisation first, and investigate second. Every mirror, offsite sync, replication task and repository prune that touches this dataset. Investigation costs minutes; a scheduled run costs the copy you were about to recover from, and it does not need anybody to make a second mistake.
- 2Mask the units you stopped, and write down which ones were enabled and when each last fired. The mask is the difference between "stopped" and "stopped and staying stopped" while a configuration-management run is in flight. The list of what you masked is also the rollback list.
- 3Freeze the surviving state before you work on it. A read-only remount, a filesystem snapshot, or a copy of what remains. Everything after this point reads that frozen state, so an investigative mistake costs nothing and the original evidence survives the recovery.
- 4Establish when the deletion happened, from the filesystem and the logs rather than from the report. The newest surviving modification times bound the window from below; the journal, the audit log and the object store's access log name the actor and the moment. The time somebody noticed is not the time it happened, and the gap between them decides how many scheduled runs have already gone.
- 5Establish what else the deletion touched. Count what is missing against the last known inventory, and check the paths nobody mentioned. Scope is what tells you whether this is a recovery or a security incident, and it is cheaper to answer now than after the restore has overwritten the evidence.
- 6Sort every copy into one of two kinds: it retains history, or it reproduces current state. Snapshots, repository archives, versioned objects and offline media retain history. Mirrors, replicas and continuous sync targets reproduce current state and after the deletion they hold the deletion. Only the first kind is a source for this recovery, and knowing which is which is the whole reason the schedule was stopped.
- 7On object storage, list versions rather than objects. A delete on a versioned bucket that carries no version id writes a zero-byte delete marker above the object; the bytes stay stored and the ordinary listing returns nothing. The measured listing showed the marker at
v2 DELand the intact 38-byte object still atv1 PUTbeneath it. An emptylsis not evidence of absence on a versioned bucket. - 8Select the recovery point explicitly, and never take "the latest". The one you want is the newest recovery point whose contents predate the deletion window — not the newest that exists, which may already have been taken after it. Name it in the record by repository, identifier and timestamp, and state what is lost between that point and now, because that gap is the cost of the recovery and somebody other than you has to accept it.
- 9Decision point — restore beside, or restore in place. Restore beside by default: it costs capacity and a comparison step, and it leaves every option open. Restore in place only when the path is unused by any live consumer, capacity genuinely will not hold a second copy, and a frozen copy of the current state already exists. Record which you chose and why.
- 10Restore into the staging path, with the ownership and permission model preserved. A restore that arrives owned by the wrong user is a second incident dressed as a success, and it is discovered by the application rather than by you.
- 11Compare the restored tree against what survived. File counts, sizes and digests, and an explicit list of what the restore brings back that is currently absent. This is where an incorrectly chosen recovery point shows itself, while nothing has been moved into the live path yet.
- 12Validate with the data owner against a business question, not a process question. "The restore completed" is a statement about a transfer. "The 27 August invoices are present and the ledger totals match the finance report" is a statement about the data. Only the second one ends the recovery, and only the owner can make it.
- 13Cut over deliberately, once and with a named authoriser. Place the recovered data, set ownership, permissions and security labels to match the surviving siblings, then restart the consuming service and watch it read the data rather than watch it start.
- 14Re-enable the schedule last, and watch the first run. Run the synchronisation in dry-run mode first and read what it proposes to delete. Re-enabling a mirror before the source is correct simply propagates the current state again, which is the same mechanism that caused the loss.
- 15Record the recovery point, the decision at each decision point, the timings and the sign-off. The chosen recovery point is the part somebody will question in a month, and the record is the only answer.
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓No timer, cron entry, platform schedule or replication task that writes this dataset is enabled, and the list of what was masked matches the list in the record.
- ✓The deletion window is stated as a bounded interval with the evidence for each bound — the newest surviving modification time below it, the log entry or access record above it.
- ✓The chosen recovery point is named by repository, identifier and timestamp, and its timestamp is inside the interval before the deletion, not merely "recent".
- ✓On object storage, a listing that shows versions returns the object versions and any delete markers; the absence of the object from an ordinary listing is explained by a marker rather than treated as loss.
- ✓The restored tree matches expectation by file count and by digest on a sample the data owner chose, not on a sample you chose.
- ✓Ownership, permissions and security labels on the recovered data match the surviving siblings, checked by listing rather than assumed from the restore tool.
- ✓The consuming service reads the recovered data through its normal path — a query that returns rows, a report that renders, a job that completes — and the data owner has confirmed the result in business terms and by name.
- ✓The first re-enabled synchronisation run was executed in dry-run mode and proposed no deletions, and the subsequent real run was watched to completion.
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶The staging restore is itself the rollback. Nothing was overwritten, so abandoning the recovery costs the staging capacity and nothing else; delete the staging tree only after sign-off.
- ↶If the restore was made in place and is wrong, the frozen copy taken before the restore is the only way back. That copy exists only if it was taken, which is why freezing the surviving state is a numbered step rather than a suggestion.
- ↶Unmask and re-enable every unit you masked, from the list you wrote down. The mask is a mutation you made and it is invisible until the night it silently skips a backup.
- ↶Re-enable any monitoring silence or alert route you suppressed while working. A silence outliving the incident is the most common reflex mutation left behind by a recovery.
- ↶Revert any speculative service restart, mount option or permission change made while investigating, and say so in the record even when it turned out to be harmless.
- ↶If a version or a delete marker was removed on the object store during the investigation, record the identifier, the time and the identity. That action is not reversible and the next responder must not have to discover it.
6 · Escalation
When the runbook isn't enough, contact:
- · Every copy in the inventory reproduces current state and none retains history: escalate immediately. This is a capability limit rather than a procedural problem, and the honest early statement is far cheaper than a day spent looking for a recovery point that was never taken.
- · The deletion is not confined to the dataset, or the pattern suggests a deliberate act: escalate to security before restoring anything. A restore overwrites the evidence about what else happened, and preserving a copy first is a decision somebody else owns.
- · The only recovery point that predates the deletion also predates a schema, format or credential change: escalate to the application owner. Recovering data the current application cannot read is a second outage, not a recovery.
- · The object versions themselves are gone rather than hidden behind delete markers: escalate to the storage platform owner. A missing marker is a listing question; a missing version is a retention or lifecycle question, and the answer is not on this host.
- · The recovery is on the critical path of an outage and is exceeding the agreed recovery time: escalate to the incident owner with the measured remaining work, so that partial service on older data can be somebody's explicit decision.
- · Nobody can authorise the cut-over: escalate rather than cutting over to tidy up. A validated staging restore with the schedule still stopped is a safe state and can be left in it for hours.
Most recoveries are races against a deadline somebody else set. This one is a race against a machine that is already running, configured correctly, and about to do exactly what it was told to do.
Stop the propagation before you investigate
The deletion has happened once. The mechanisms that would repeat it on every other copy are still on a schedule, and they do not care that an incident is open.
$ rsync -a --delete src/ mirror/--- mirror now ---
mirror/orders.csv
mirror/reports/q3.txt
invoice.txt recoverable from the mirror? NO - the mirror deleted it too
Elapsed time between the mistake and the loss of the only other copy:
one scheduled interval. Nobody had to make a second mistake.One interval. That is the budget, and it is the same budget whether the interval is nightly or every five minutes. So the first commands of this runbook are not diagnostic.
# Substitute the units, jobs and schedules your estate actually runs.
SYNC_UNITS=(rbdr-mirror.timer rbdr-offsite-sync.timer rbdr-prune.timer)
for u in "${SYNC_UNITS[@]}"; do
systemctl stop "$u"
systemctl mask "$u"
done
systemctl list-timers --all
crontab -l -u root
Masking matters as much as stopping. A stopped timer whose unit is still enabled is one configuration-management run away from starting again, and that run is also on a schedule.
Establish the window, and what else it touched
Two facts, in this order. Newest surviving modification times bound the deletion from below; logs and access records bound it from above and name the actor.
DATASET=/srv/rbdr-ledger
find "$DATASET" -xdev -printf '%TY-%Tm-%Td %TH:%TM:%TS %p\n' | sort -r | head -20
journalctl -u rbdr-mirror.service --since '2026-08-27 00:00' --no-pager
last -F -n 30
Then scope. A deletion that stops at one directory is usually a mistake. One
accompanied by renamed or unreadable files elsewhere is a different incident:
the same capture, one day later, shows a mirror faithfully reproducing
orders.csv.locked and reports/q3.txt.locked on schedule. Scope is what
decides whether the next person you call is the data owner or security.
Sort the copies: history, or current state
| Copy | What it holds after the deletion | Source for this recovery |
|---|---|---|
rsync --delete mirror | current state, deletion included | No |
| Storage or database replica | current state, deletion included | No |
| Filesystem snapshot older than the window | state at snapshot time | Yes |
| restic or Borg repository | every retained archive | Yes |
| Versioned object bucket | every version until lifecycle expiry | Yes |
| Offline or offsite media | state at write time | Yes, slowest |
Only the lower half is a source. This is the distinction Part III of this course draws between a copy and a recovery point, and it is the reason the schedule was stopped before the inventory was read: the top half is where the copies migrate to when a scheduled run fires.
On object storage, list versions, not objects
$ mc rm prod/rbdr-immutable/backup-0900.tar Created delete marker `prod/rbdr-immutable/backup-0900.tar` (versionId=4b3c593c-e8ad-444d-aa87-89e380a1fbae).
>>> exit code: 0
--- is the object still listed? ---
>>> exit code: 0
--- and with versions shown? ---
[2026-08-28 13:28:22 UTC] 0B STANDARD 4b3c593c-e8ad-444d-aa87-89e380a1fbae v2 DEL backup-0900.tar
[2026-08-28 13:28:20 UTC] 38B STANDARD 133fd99f-1f98-41c0-9d08-95e6e2944157 v1 PUT backup-0900.tar
>>> exit code: 0The ordinary listing returned nothing. The listing that shows versions returned
a zero-byte v2 DEL marker sitting above a 38-byte v1 PUT object that was
never touched. An empty listing on a versioned bucket is a statement about what
is current, not about what is stored, and reporting the data as lost on that
evidence is the most expensive mistake available in this procedure.
BUCKET=rbdr-ledger-backups
KEY=ledger/2026-08-27/orders.csv
aws s3api list-object-versions --bucket "$BUCKET" --prefix "$KEY" \
--query '{V:Versions[].{Id:VersionId,At:LastModified,Bytes:Size},D:DeleteMarkers[].{Id:VersionId,At:LastModified}}'
Part X of this course covers the retention and lifecycle rules that decide how long those versions remain; here it is enough to know they are a different listing, not a different bucket.
Recovery point selection
RBDR_REPO=sftp:rbdr-backup@vault.example.net:/srv/rbdr-repo
RESTORE_ROOT=/srv/rbdr-restore-2026-08-28
SNAP=a1b2c3d4
restic -r "$RBDR_REPO" snapshots --path /srv/rbdr-ledger
mkdir -p "$RESTORE_ROOT"
restic -r "$RBDR_REPO" restore "$SNAP" --target "$RESTORE_ROOT"
diff -rq "$RESTORE_ROOT/srv/rbdr-ledger" /srv/rbdr-ledger | head -40
Decision point: restore beside, or restore in place
| Condition | Beside | In place |
|---|---|---|
| A live consumer reads the path | Required | Not available |
| Capacity for a second full copy | Available | Not available |
| A frozen copy of current state exists | Optional | Required |
| The recovery point is not yet confirmed | Required | Not available |
| Time pressure is the binding constraint | Costs a comparison step | Costs the fallback |
Beside is the default. It buys a comparison against the surviving state, it keeps the pre-restore evidence intact, and it makes abandoning the recovery free. In place is chosen only when all three of its conditions hold, and the third one is the one people skip: without a frozen copy first, an in-place restore of the wrong recovery point has no way back.
Abort criteria
Stop the procedure and escalate, rather than continuing, when any of these is true:
- The inventory contains no copy that retains history. Continuing produces hours of work and the same answer.
- The scope of the deletion grows while you are working on it, or the surviving files change without your action. Something is still running.
- The best available recovery point predates a schema or format change the application cannot read. That is an application decision.
- The restore requires credentials that would also let you remove versions on the object store, and no read-only identity can be issued in time.
- The staging restore does not match expectation and no other recovery point is closer. Do not cut over to see what happens.
Abort is not failure. A stopped schedule, a frozen surviving state and an unvalidated staging restore is a stable position that can be held while somebody more senior decides.
Business validation with the data owner
A completed transfer is a fact about bytes. The recovery is not finished until somebody who uses the data says it is right, in the vocabulary of the business: the invoices for the affected days are present, the ledger reconciles against the finance report, the report that failed this morning now renders.
Ask for a sample the owner chooses. A sample you choose validates the files you already looked at.
OWNER_SAMPLE="$RESTORE_ROOT/srv/rbdr-ledger"
find "$OWNER_SAMPLE" -type f | wc -l
md5sum "$OWNER_SAMPLE/orders.csv"
Where a digest was recorded before the incident, it settles the question
outright. The capture’s 09:00 source tree recorded
orders.csv md5: 9eb4e2ad8e08e1dcaaf87ababab964b0 before anything was deleted.
Then, and only then, cut over, and re-enable the schedule afterwards with a dry-run first so the first synchronisation is watched rather than trusted.
What to record
The window and the evidence for both of its bounds. Every job stopped and masked, so the rollback list exists. The recovery point, by repository, identifier and timestamp. The gap the owner accepted. Both decision points, with the condition that decided them. The validation the owner performed, by name. And the time the schedule was re-enabled, because the next person to read this record will be asking whether it ever was.