Skip to main content
RunBook Academy

← All runbooks in Backup & DR

medium riskservice affecting~30 min

Restore a deleted file

1 · Prerequisites

Confirm every item is in place before any state change.

  • The absolute path of the file, taken from whoever reported it rather than inferred from a directory listing. A restore to a path one character away from the intended one looks exactly like a restore that did nothing.
  • Read access to the repository or snapshot store holding the recovery points, and the credential needed to open it, obtained from somewhere other than the host you are recovering.
  • The name of the person or team who owns the content, and a way to reach them during this procedure. They are the only party who can confirm that what came back is the version they expected.
  • Knowledge of whether the target path sits inside a running service's data directory, and the identity of whoever is authorised to stop that service.
  • A staging directory on the target host, outside every service data directory, with free space for what the restore will unpack.
  • The ownership, mode and security context the consumer of the file expects, or a sibling file in the same directory to read them from.
  • Somewhere to write the record this procedure produces: the recovery point used, the RPO it paid, and how the file went away.

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · Confirm the file is actually missing, and confirm the path. sudo ls -la "$(dirname "$TARGET")" and sudo stat "$TARGET". A file that was renamed, a path with a transposed character, and a genuine deletion all reach you as "it is gone", and only the first two are cheap to fix.
  • · Establish when the file was last correct. The directory's mtime brackets the deletion, the service journal usually names it, and the person reporting it can say when they last opened the file. You need a time before you look at any recovery point, because every later decision is a comparison against it.
  • · Establish how the file went away. A human rm, a deployment that rewrote a directory, a log rotation, a failing disk and a compromised account all produce a missing file and all imply different recovery points. This question is not post-incident tidying; it decides how far back you have to go.
  • · Determine whether the target path is inside a running service's data directory. sudo fuser -v "$(dirname "$TARGET")" and sudo lsof +D "$(dirname "$TARGET")". If a process holds that directory open, this procedure aborts here and does not resume until the service has been stopped by someone authorised to stop it.
  • · Confirm the repository opens and lists recovery points covering the damage window. sudo -E restic snapshots --tag daily must exit 0 and print at least one row. Exit 12 with Fatal: wrong password or no key found means the credential is wrong, not that the repository is empty, and that is a key-recovery problem rather than this procedure.
  • · Confirm the staging directory exists, has free space, and is not inside any service data directory. df -h "$RB". Restoring into the path you are trying to protect is how a recovery becomes an outage.
  • · Confirm you can reach the content owner now. A restore you cannot get confirmed is a restore you cannot close, and discovering that after the file is in place means the file sits there unvalidated.
  • · Record the start time. date -Is | tee "$RB/record.txt". The elapsed time of a single-file recovery is one of the few recovery numbers most estates measure often enough to trust.

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Open the written record before you touch the repository. Path, reported time, damage time, who reported it, who owns it. Everything the procedure decides gets appended to this file, because the questions asked afterwards are all about the decisions, not about the copy.
  2. 2Fix the damage time from evidence, not from the report. Read the parent directory's mtime, the service journal around it, and the audit log if the host keeps one. Write the resulting timestamp into the record. This is the reference point every subsequent step is measured against.
  3. 3List the recovery points and read the Time column, not the bottom row. sudo -E restic snapshots --tag daily. Listings are sorted oldest first, so the newest sits at the bottom and the eye lands on it. That position is a rendering artefact, not a recommendation.
  4. 4Select the newest recovery point whose time is earlier than the damage time, and write the ID into the record before restoring anything. This is the decision the runbook exists for. The newest copy is right for a deletion noticed immediately and wrong for a corruption that has been backed up on schedule for a week, and the two arrive on the phone sounding identical.
  5. 5Decision point — restore in place, or restore beside and let the owner compare. Restore in place only when the path is currently absent, the consumer is not running, and the owner has already described the content they expect. Restore beside whenever the path still holds something, whenever the file is contested, and whenever the owner needs to look before you commit.
  6. 6Decision point — is this a configuration file whose restoration changes running behaviour? If a service reads the path, putting an older version back is a configuration change, not a file copy. Treat it as one: know which settings differ, know who approved the newer values, and schedule the reload rather than letting the next unrelated restart discover it.
  7. 7Restore into the staging directory, never onto the live path first. sudo -E restic restore "$SNAP" --target "$STAGE". The staged copy is what makes the comparison possible, and the comparison is what turns "I restored a file" into "I know what the restore changed".
  8. 8Read the lines the restore prints, and its exit code. The first line names the snapshot and the timestamp it is unpacking; the summary line names how many files arrived. A non-zero exit, or a summary reading Restored 6 / 7, means part of the tree did not come back and the staged copy must not be installed.
  9. 9Preserve whatever is currently at the target path before writing anything. sudo cp -a "$TARGET" "$RB/evidence/". cp -a keeps mode, ownership and timestamps, and that metadata is most of the evidence about how the file came to be wrong.
  10. 10Compare the staged copy against the live path. sudo diff -u "$STAGE$TARGET" "$TARGET". An empty diff on a file you believed was damaged means the recovery point you chose already contains the damage; go back one row and repeat. On a genuine deletion the comparison is against the preserved neighbour and the checksum instead.
  11. 11Place the file with the ownership, mode and security context the consumer needs. Use install rather than cp: cp onto an existing path writes through the existing inode and leaves whatever owner and mode were already there, producing a byte-perfect file the service still cannot open.
  12. 12Have the owner confirm the content is the version they expected. Show them the file, or the diff, or the specific values they named earlier. "A file exists at that path" is a filesystem observation; only the owner can say whether the recovery worked.
  13. 13Record the recovery point used, the RPO it paid, and how the deletion happened. The RPO paid is the interval between the chosen recovery point and the damage time, and everything legitimately written into the file inside that window is gone. Say so explicitly before anyone is told the incident is closed.

4 · Verification

Confirm the procedure actually fixed the problem.

  • grep -c "chosen recovery point" "$RB/record.txt" prints 1 and exits 0. The record names the damage time, the recovery point ID, and the reason that point was chosen in preference to the newest one.
  • grep -m1 "restoring snapshot" "$RB/record.txt" exits 0 and the timestamp on the line it prints is earlier than the recorded damage time. That line is restic naming the recovery point it actually unpacked, which is the only cheap check that you took the one you chose.
  • grep -c "^Fatal:" "$RB/record.txt" prints 0. Any Fatal: line in the captured restore output means the staged tree is incomplete and must not be installed, whatever the summary line says.
  • sudo stat -c '%n owner=%U:%G mode=%a size=%s' "$TARGET" exits 0 and prints the ownership and mode the consumer expects, not the staging directory's defaults.
  • sudo sha256sum "$STAGE$TARGET" "$TARGET" exits 0 and prints the same digest on both lines. Two different digests mean install placed something other than the copy you compared.
  • On an SELinux host, sudo restorecon -nv "$TARGET" exits 0 and prints nothing, meaning the label already matches policy; any line it prints is a relabel it would have made, and the consumer would have failed on that label.
  • If the path still held something when you started, sudo test -e "$RB/evidence/$(basename "$TARGET")" exits 0 and sudo stat -c '%U:%G %a %y' "$RB/evidence/$(basename "$TARGET")" prints the metadata the live file carried before the change.
  • If a service consumes the path, sudo systemctl reload rbdr-appd exits 0 AND sudo journalctl -u rbdr-appd --since "-2 min" --no-pager shows the service reading and parsing the file. Active: active (running) is a claim about the unit, not about the file.
  • The content owner has stated, in writing, that the content is the version they expected — naming a value, a row, a date or a setting, not merely acknowledging that a file is present. This is the only line here with no command and no exit code, and the only one that closes the procedure.
  • grep -c "RPO paid" "$RB/record.txt" prints 1 and exits 0. The record states the interval this recovery paid and names what was written into the file inside that window and is therefore gone.

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • If the restored content turns out to be the wrong version, put the preserved copy back with install using the metadata recorded before the change, and start again from the recovery point selection step rather than reaching for the next snapshot down the list.
  • If the file was placed beside the live path for comparison, remove the .rbdr-restored sibling once the decision is made. A stray restored copy in a configuration directory is read by the next person as an intentional file.
  • If a service was reloaded to pick up the restored file and its behaviour changed for the worse, roll the configuration back first and the file second; the running service is the urgent half.
  • Undo the mutations made by reflex: an alert silence taken out at the start, a monitoring check disabled to stop the noise, a speculative service restart. These outlive the incident and are invisible in the record unless you write them down.
  • Remove the staging directory once the evidence artefacts have been copied out of it. A staged restore left in a home directory is an unlabelled copy of production data with none of the repository's protections.
  • Whatever the outcome, leave the record in place. A recovery that was rolled back is a more useful record than one that worked, and it is the only account of why the first recovery point was rejected.

6 · Escalation

When the runbook isn't enough, contact:

  • · A process holds the target directory open and nobody present is authorised to stop the service: escalate to the service owner. Do not restore into a data directory underneath a running process, and do not stop the service on your own judgement to make the restore possible.
  • · No recovery point predates the damage time: escalate to whoever owns the backup schedule. This is a retention or a schedule failure, and the answer is a decision about how much loss is acceptable, which is not the responder's to make.
  • · The deletion looks deliberate, or the account that performed it is not one that should have had access: stop, escalate to security, and preserve the current state. A restore overwrites the evidence of how the file went away.
  • · The owner cannot confirm the restored content, or disputes it: escalate rather than closing. An unconfirmed restore that is quietly declared finished is how a wrong version reaches production and stays there.
  • · The file is one of many missing from the same directory, or the same pattern appears on other hosts: escalate immediately. Single-file recovery is the wrong procedure for what is now a different incident.
  • · The restore prints ciphertext verification failed or exits non-zero, or the two checksums disagree: escalate to whoever owns the repository. A repository that cannot return one file has said something about every other file it holds.
  • · The repository refuses the credential with Fatal: wrong password or no key found: escalate to whoever holds the escrowed passphrase. Every byte may be present and still unreadable, and no amount of retrying on this host changes that.

One file is missing. This is the recovery most estates perform most often, and the one most often performed badly, because it feels too small to deserve a procedure.

The command that copies the bytes back is a single line. Everything that decides whether the recovery worked sits on either side of it. The procedure is filed as service-affecting rather than informational for one reason: it ends by writing a file into a live path, and if a service reads that path, the last step of a file recovery is a configuration change.

Set up the working paths first, so the rest of the runbook has names to refer to.

RB="$HOME/rbdr-restore-01"
TARGET=/etc/rbdr-appd/appd.conf
mkdir -p "$RB/staging" "$RB/evidence"
date -Is | tee "$RB/record.txt"

Recovery point selection is the whole runbook

Establish the damage time from the host, not from the report:

DIR=$(dirname "$TARGET")
sudo stat -c '%n mtime=%y ctime=%z' "$DIR"
sudo journalctl --since '-48 hours' --no-pager | grep -F "$TARGET" | tail -20
sudo ausearch -f "$TARGET" 2>/dev/null | tail -40 || echo "auditd holds no record of this path"

Three sources, because none of them is reliable alone. The directory mtime moves when anything in it changes, so it brackets the deletion rather than dating it. The journal names the file only if something logged it. The audit log is authoritative and frequently absent. Take the earliest time all three are consistent with, and write that down as the damage time.

Then list what is available and read down the Time column.

Read-only / Safea recovery point listing, sorted oldest first
$ restic snapshots
ID        Time                 Host          Tags        Paths       Size
-------------------------------------------------------------------------------
3fe43af4  2026-08-28 13:27:02  8211a08b55c3  daily       /work/prod  60.000 MiB
3e349a12  2026-08-28 13:27:03  8211a08b55c3  daily       /work/prod  60.000 MiB
-------------------------------------------------------------------------------
Timestamps shown in local time
2 snapshots

The newest row is at the bottom because the listing is sorted, not because it is the answer. Take the last row whose time is earlier than the damage time, and write the choice down before you act on it.

DAMAGED_AT=$(sudo stat -c %Y "$DIR")
# Replace this with the short ID you chose from your own listing above.
SNAP=00000000
sudo -E restic snapshots "$SNAP"
printf 'damage time: %s\nchosen recovery point: %s\n' "$(date -d "@$DAMAGED_AT" -Is)" "$SNAP" \
  | tee -a "$RB/record.txt"

Decision point 1 — in place, or beside

SituationRestore
Path is absent, consumer stopped, owner has described the expected contentIn place
Path still holds somethingBeside, then compare
Two people disagree about which version is correctBeside, always
The owner needs to look before you commitBeside

Restoring beside costs one extra path and buys the owner a comparison they can actually make. Capture the restore output into the record as it runs: the first line it prints is evidence, and it is gone from the terminal by the time anyone asks for it.

STAGE="$RB/staging/$SNAP"
sudo -E restic restore "$SNAP" --target "$STAGE" 2>&1 | tee -a "$RB/record.txt"
OWNER=$(sudo stat -c '%U:%G' "$STAGE$TARGET")
MODE=$(sudo stat -c '%a' "$STAGE$TARGET")
sudo install -o "${OWNER%%:*}" -g "${OWNER##*:}" -m "$MODE" "$STAGE$TARGET" "$TARGET.rbdr-restored"
Read-only / Safethe restore names the point it is unpacking
$ restic restore 3fe43af4 --target /work/restore
restoring snapshot 3fe43af4 of [/work/prod] at 2026-08-28 13:27:02.65376235 +0000 UTC by root@8211a08b55c3 to /work/restore
Summary: Restored 7 files/dirs (60.000 MiB) in 0:00
>>> exit code: 0

Read the timestamp in that first line against the damage time you wrote down. It is the last free check that you took the point you intended. Ownership and mode come from the staged copy rather than from a sibling file, because the staged copy is what the file looked like when it was still correct.

Decision point 2 — is this a configuration file?

If a service reads the path, putting an older copy back is a configuration change wearing a restore’s clothing. It will change running behaviour, and it will do so at the next restart rather than now, which is worse: the change lands during someone else’s unrelated maintenance.

Before installing, answer three questions and put the answers in the record. Which settings differ between the restored version and what is running. Who approved the newer values, if anybody did. When the service will be reloaded, deliberately, by you.

Abort criteria

Stop and do not proceed if any of these hold:

  • The target path is inside a running service’s data directory and the service has not been stopped. A process holding that directory open has its own idea of what is on disk. Writing underneath it produces corruption that is attributed to the restore.
  • No recovery point predates the damage time. There is nothing here to choose between; this is a retention question for the schedule owner.
  • The deletion looks deliberate or unauthorised. The current state is evidence, and a restore overwrites it.
  • The same file is missing from several hosts. This is no longer a single-file recovery.
  • The restore exited non-zero, or its output contains a Fatal: line. The staged tree is incomplete; installing part of it is worse than installing nothing.
sudo fuser -v "$DIR" 2>&1 || true
sudo lsof +D "$DIR" 2>/dev/null | head -20 || true
Read-only / Safewhat a hit looks like: a process holding a service data directory open
$ fuser -v /var/lib/rbdr-appd
                     USER        PID ACCESS COMMAND
/var/lib/rbdr-appd:  rbdr-appd  41277 F....c rbdr-appd

Illustrative output

Any output here is an abort, not a warning.

Signatures the restore returns, and what each one means

A restore has more than two outcomes, and the difference between them is visible only in the lines the command printed. Read the table before you read the summary.

SignatureCauseWhat it means for this procedure
Summary: Restored 7 files/dirs, exit 0Every path in the recovery point reached the staging directory.The copy is complete. It says nothing about which version you took; continue to the comparison.
ciphertext verification failed, then Fatal: There were 1 errors, exit 1A data pack in the repository is damaged. restic wrote what it could and named the file it could not.Abort. Do not install a partial tree. Take the file from a second copy of the repository and escalate to the repository owner.
Fatal: wrong password or no key found, exit 12The repository is intact but the passphrase or key file this host offers does not open it.Abort. This is a key-recovery problem, not a restore problem; go to the escrowed passphrase and do not keep retrying here.
Exit 0, then an empty diff against a file you believed was damagedThe recovery point you chose already contains the damage.Go back one row up the listing, not forward. Selecting again is the fix; restoring again is not.
install cannot stat $STAGE$TARGETThe recovery point does not contain that path at all.Check the path character by character, then check whether it was ever inside the backup’s include set. A path never backed up is a schedule incident.

The second row is the one that catches people, because it is quiet. In the capture this course took, a plain restic check on that same repository reported no errors were found and exited 0; only the --read-data form of that check found the damaged pack, and the restore below is what the damage looked like from the responder’s side.

Read-only / Safea restore that failed, and named the file it could not return
$ restic restore 3fe43af4 --target /work/restore2
restoring snapshot 3fe43af4 of [/work/prod] at 2026-08-28 13:27:02.65376235 +0000 UTC by root@8211a08b55c3 to /work/restore2
ignoring error for /work/prod/db/data.bin: decrypting blob <data/9a6d59cf> from pack 2c3be6d1c75844d268248b7a2a90e42f5d325095bd381b31c25bcc3d0179951f failed: ciphertext verification failed
Summary: Restored 6 / 7 files/dirs (59.401 MiB / 60.000 MiB) in 0:00
Fatal: There were 1 errors
>>> exit code: 1

Note the summary: Restored 6 / 7. A responder reading only the last two words of that line would see files restored and move on.

Business validation

Preserve first, compare second, install third:

if [ -e "$TARGET" ]; then
  sudo cp -a "$TARGET" "$RB/evidence/"
  sudo diff -u "$STAGE$TARGET" "$TARGET" | tee "$RB/evidence/restored-vs-live.diff"
else
  echo "target path absent: this is a deletion, so there is nothing to diff against" \
    | tee "$RB/evidence/restored-vs-live.diff"
fi
sudo install -o "${OWNER%%:*}" -g "${OWNER##*:}" -m "$MODE" "$STAGE$TARGET" "$TARGET"
command -v restorecon >/dev/null && sudo restorecon -v "$TARGET"
sudo stat -c '%n owner=%U:%G mode=%a size=%s' "$TARGET"
sudo sha256sum "$STAGE$TARGET" "$TARGET"

install sets ownership and mode as part of placing the file. cp onto an existing path writes through the existing inode and leaves whatever owner and mode were already there — a byte-perfect file the service still cannot open, failing with a message that points at permissions. The two checksums on the last line must match; if they do not, something other than the copy you compared is now at the target path.

Then the part that is not a filesystem operation. Show the owner the file, or the diff, or the specific values they named earlier, and get their answer in writing. “A file exists at that path” is something you can see. Whether it is the version they expected is something only they can say, and it is the only statement that closes this procedure. If a service consumes the path, reload it deliberately and read its own log: a unit that is active has told you about the unit, not about the file it parsed.

What to record

SNAP_AT=$(grep -m1 'restoring snapshot' "$RB/record.txt" \
  | sed -E 's/.* at ([0-9-]+ [0-9:]+)[.0-9]* ([+-][0-9]{4}).*/\1 \2/')
{
  printf 'target: %s\n' "$TARGET"
  printf 'recovery point: %s taken %s\n' "$SNAP" "$SNAP_AT"
  printf 'damage time: %s\n' "$(date -d "@$DAMAGED_AT" -Is)"
  printf 'RPO paid: %s seconds\n' "$(( DAMAGED_AT - $(date -d "$SNAP_AT" +%s) ))"
} | tee -a "$RB/record.txt"

Three things, and none of them is the restore command:

  • The recovery point used, and why it was chosen over the newest one.
  • The RPO this recovery paid — the interval between that point and the damage time, computed above from the timestamp restic itself printed rather than from the one you meant to use. Anything legitimately written into the file inside that window is gone, and the owner has to hear that from you rather than discover it later.
  • How the file went away. Without it, the same recovery happens again next month and the record says only that it was performed.

References

  1. restic documentation, Restoring from backup
  2. restic documentation, Working with repositories
  3. restic documentation, Troubleshooting
  4. install(1) manual page
  5. cp(1) manual page
  6. stat(1) manual page
  7. diff(1) manual page
  8. restorecon(8) manual page