What is it? #
A backup is a copy of your data that can be restored after loss. The only backup that counts is one you have successfully restored.
What needs backing up is usually narrower than people assume: the database, user-uploaded files, and configuration. Application code lives in git; installed packages can be reinstalled.
The 3-2-1 rule is the standard guideline: three copies, on two different media, with one offsite.
Restores must be tested. A backup job that has run nightly for a year and produces unusable files is worse than none, because you relied on it.
Think of it like this #
Insurance you never check. Paying the premium every month feels like protection, but nobody knows whether the policy actually covers the house until there is a fire.
Testing the restore is reading the policy before you need it.
Simple example #
A server holds a PostgreSQL database, uploaded documents and a set of configuration files. You need nightly automated backups, offsite copies, sensible retention and a documented restore procedure.
Code #
#!/usr/bin/env bash
# /srv/scripts/backup.sh
set -euo pipefail
STAMP=$(date +%F-%H%M)
DIR=/var/backups/app
mkdir -p "$DIR"
# 1. Database — a consistent logical dump
pg_dump --format=custom --no-owner "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>D</mi><mi>A</mi><mi>T</mi><mi>A</mi><mi>B</mi><mi>A</mi><mi>S</mi><msub><mi>E</mi><mi>U</mi></msub><mi>R</mi><mi>L</mi><mi mathvariant="normal">"</mi><mo>></mo><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">DATABASE_URL" > "</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.8444em;vertical-align:-0.15em;"></span><span class="mord mathnormal" style="margin-right:0.0278em;">D</span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">T</span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.0502em;">B</span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.0576em;">S</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3283em;"><span style="top:-2.55em;margin-left:-0.0576em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="katex-sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.109em;">U</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="mord mathnormal">L</span><span class="mord">"</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">></span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="katex-base"><span class="katex-strut" style="height:0.6944em;"></span><span class="mord">"</span></span></span></span>DIR/db-$STAMP.dump"
# 2. Uploaded files — incremental, only what changed
rsync -a --delete /srv/app/uploads/ "$DIR/uploads/"
# 3. Configuration
tar czf "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>D</mi><mi>I</mi><mi>R</mi><mi mathvariant="normal">/</mi><mi>c</mi><mi>o</mi><mi>n</mi><mi>f</mi><mi>i</mi><mi>g</mi><mo>−</mo></mrow><annotation encoding="application/x-tex">DIR/config-</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal" style="margin-right:0.0278em;">D</span><span class="mord mathnormal" style="margin-right:0.0785em;">I</span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="mord">/</span><span class="mord mathnormal">co</span><span class="mord mathnormal">n</span><span class="mord mathnormal" style="margin-right:0.1076em;">f</span><span class="mord mathnormal">i</span><span class="mord mathnormal" style="margin-right:0.0359em;">g</span><span class="mord">−</span></span></span></span>STAMP.tar.gz" /etc/nginx /etc/systemd/system/myapp.service
# 4. Encrypt before it leaves the machine
gpg --batch --yes --encrypt --recipient [email protected] \
--output "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>D</mi><mi>I</mi><mi>R</mi><mi mathvariant="normal">/</mi><mi>d</mi><mi>b</mi><mo>−</mo></mrow><annotation encoding="application/x-tex">DIR/db-</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal" style="margin-right:0.0278em;">D</span><span class="mord mathnormal" style="margin-right:0.0785em;">I</span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="mord">/</span><span class="mord mathnormal">d</span><span class="mord mathnormal">b</span><span class="mord">−</span></span></span></span>STAMP.dump.gpg" "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>D</mi><mi>I</mi><mi>R</mi><mi mathvariant="normal">/</mi><mi>d</mi><mi>b</mi><mo>−</mo></mrow><annotation encoding="application/x-tex">DIR/db-</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal" style="margin-right:0.0278em;">D</span><span class="mord mathnormal" style="margin-right:0.0785em;">I</span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="mord">/</span><span class="mord mathnormal">d</span><span class="mord mathnormal">b</span><span class="mord">−</span></span></span></span>STAMP.dump"
rm "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>D</mi><mi>I</mi><mi>R</mi><mi mathvariant="normal">/</mi><mi>d</mi><mi>b</mi><mo>−</mo></mrow><annotation encoding="application/x-tex">DIR/db-</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal" style="margin-right:0.0278em;">D</span><span class="mord mathnormal" style="margin-right:0.0785em;">I</span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="mord">/</span><span class="mord mathnormal">d</span><span class="mord mathnormal">b</span><span class="mord">−</span></span></span></span>STAMP.dump"
# 5. Offsite copy
rclone copy "$DIR" remote:app-backups/ --transfers 4
# 6. Retention: keep 14 days locally
find "$DIR" -name "db-*.dump.gpg" -mtime +14 -delete
# 7. Report success to a monitor that alerts if the ping stops arriving
curl -fsS -m 10 "https://hc-ping.com/YOUR-UUID" || true
echo "[$(date -Is)] backup completed"
# The part most people skip: a tested restore
createdb restore_test
pg_restore --dbname=restore_test --no-owner "$DIR/db-2026-09-22-0200.dump"
psql restore_test -c "SELECT count(*) FROM orders;" # does the data look right?
dropdb restore_test
The 3-2-1 rule
3 copies the live data plus two backups
2 media different storage: local disk and object storage
1 offsite a different physical location or provider
Why offsite matters: a failed disk, a deleted account, a ransomware
encryption run or a datacentre fire can take the server and any backup
stored on it at the same time.
What to back up, and what not to
back up database, uploads, configuration, secrets (encrypted),
TLS certificates if not easily reissued
skip application code (git), installed packages (reinstallable),
caches, temporary files, logs beyond your retention policy
Snapshots of the whole machine are convenient but large and slow to
restore selectively. Most teams use both: snapshots for the machine,
logical dumps for the data.
How it works #
pg_dump --format=custom produces a consistent logical snapshot taken inside a transaction, so the dump reflects a single point in time even while writes continue. Copying database files directly while the server is running produces a corrupt backup.
rsync -a --delete mirrors the uploads directory, transferring only changes. The --delete flag keeps the mirror accurate, which also means a deletion propagates — hence the need for retention and versioning at the destination.
Encrypting before upload means the offsite copy is useless to anyone who obtains it. Backups contain everything sensitive in your system, so they deserve the same protection as the live data.
Retention balances cost against recovery window. Fourteen days locally with longer retention offsite is a common arrangement, because some problems are only discovered weeks later.
The monitoring ping is what turns a silent failure into an alert. Services like this expect a check-in on a schedule and alert when it does not arrive — which catches the case where the script never ran at all.
The restore test is the part that makes the whole thing real. Restoring into a scratch database and running a sanity query proves the file is usable, which is the only claim that matters.
set -euo pipefail stops the script at the first failure. Without it, a failed dump still produces a compressed, encrypted, uploaded file that happens to be empty.
Real-world use #
The failure mode people actually experience is not the absence of backups. It is backups that were corrupt, incomplete, or could not be restored under pressure.
Restore testing should be scheduled, not ad hoc. A quarterly restore into a scratch environment, timed and documented, converts a hope into a known recovery time.
Ransomware has made offsite and immutable backups essential. Backups on a mounted network share get encrypted along with everything else, which is why object storage with versioning and delete protection has become the standard destination.
Recovery objectives should be explicit. How much data can you afford to lose, and how long can you be down? Nightly backups mean up to 24 hours of loss; continuous archiving reduces that to minutes at higher cost.
Documenting the restore procedure matters as much as the backup. During an incident, at an unsociable hour, nobody should be working out the commands from scratch.
Common mistakes #
- Never testing a restore, so the backup is an assumption rather than a plan.
- Copying database files while the server is running instead of using a proper dump.
- Keeping the only backup on the same machine or the same account.
- Unencrypted offsite backups containing all your sensitive data.
- No alert when the backup job fails or silently stops running.
Practice #
Write a backup script that dumps a database, mirrors an uploads directory, encrypts the output and copies it offsite, with 14-day retention and a success ping. Then restore the dump into a scratch database, run a query to verify the data, and write down how long the restore took.