A Weekend Backup Script That Has Saved Me Three Times
A real, working, copy-pasteable backup script with cron, logging, and a 30-day retention policy. Plus the three times it has saved me, with dates.
The script, top to bottomThis is the script I run on every Linux host I care about. About 80 lines of Python. Reads a config file, backs up the listed directories to a timestamped tarball, rotates old backups, and writes a one-line status to a log file. That is the whole thing.The config file, backup.conf, looks like this:
[backup]
source_dirs = /etc/nginx, /etc/letsencrypt, /var/www, /opt/jobs
dest_dir = /var/backups/daily
keep_days = 30
log_file = /var/log/backup.log
state_file = /var/backups/state.json
One config per host. Drop the script in /opt/backup.py, the config in /opt/backup.conf, and you are 90% of the way there. The remaining 10% is the cron entry, the log rotation, and the alert path, which I will get to.The full script:
#!/usr/bin/env python3
"""
backup.py — timestamped tarball backups with rotation and logging.
Reads backup.conf for settings. Exits 0 on success, 1 on failure.
Writes a one-line status to log_file. Updates state_file on success.
"""
import configparser
import datetime
import json
import os
import shutil
import sys
import tarfile
import pathlib
CONFIG_PATH = os.environ.get("BACKUP_CONFIG", "/opt/backup.conf")
def load_config():
cfg = configparser.ConfigParser()
cfg.read(CONFIG_PATH)
return {
"source_dirs": [d.strip() for d in cfg.get("backup", "source_dirs").split(",")],
"dest_dir": cfg.get("backup", "dest_dir"),
"keep_days": cfg.getint("backup", "keep_days"),
"log_file": cfg.get("backup", "log_file"),
"state_file": cfg.get("backup", "state_file"),
}
def log(msg, log_file):
line = f"{datetime.datetime.utcnow().isoformat()}Z {msg}"
print(line)
with open(log_file, "a") as f:
f.write(line + "\n")
def rotate(dest_dir, keep_days, log_file):
cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=keep_days)
removed = 0
for entry in pathlib.Path(dest_dir).glob("backup-*.tar.gz"):
if datetime.datetime.utcfromtimestamp(entry.stat().st_mtime) < cutoff:
entry.unlink()
removed += 1
log(f"ROTATE removed={removed} keep_days={keep_days}", log_file)
def main():
cfg = load_config()
os.makedirs(cfg["dest_dir"], exist_ok=True)
timestamp = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
archive_name = f"backup-{timestamp}.tar.gz"
archive_path = os.path.join(cfg["dest_dir"], archive_name)
try:
with tarfile.open(archive_path, "w:gz") as tar:
for src in cfg["source_dirs"]:
if not os.path.exists(src):
log(f"SKIP missing={src}", cfg["log_file"])
continue
tar.add(src, arcname=os.path.basename(src))
size = os.path.getsize(archive_path)
with open(cfg["state_file"], "w") as f:
json.dump({
"last_run": timestamp,
"last_status": "OK",
"last_archive": archive_name,
"last_size": size,
}, f)
log(f"OK archive={archive_name} size={size}", cfg["log_file"])
rotate(cfg["dest_dir"], cfg["keep_days"], cfg["log_file"])
return 0
except Exception as e:
with open(cfg["state_file"], "w") as f:
json.dump({"last_run": timestamp, "last_status": "FAIL", "error": str(e)}, f)
log(f"FAIL error={e}", cfg["log_file"])
return 1
if __name__ == "__main__":
sys.exit(main())
The cron entryOnce a day, at 3:14am (a time nobody else is using, so the host is quiet):
14 3 * * * /usr/bin/python3 /opt/backup.py || /opt/backup-alert.sh
That is the whole cron line. Run the script. If the script exits non-zero (which only happens on a real failure inside the tar block), run the alert script.The alert script, backup-alert.sh, is 8 lines of bash that emails me. I am not going to print it here because the point is the pattern, not the specific alert channel. Use email, SMS, Telegram, Discord, a webhook to PagerDuty, whatever. The point is that the script wakes a human when it fails, so the failure does not sit silently until you need the backup and discover you do not have one.The state file is the part that mattersWhen I wrote the first version of this script, I had the cron entry and the log file, and that was it. I figured I would just check the log file periodically. The first time the script failed, I did not check the log file for nine days. By the time I checked, the rotated log file had been deleted by logrotate, and I had no way to know whether the backups were running or not.That is the part I am most embarrassed about. The state file is the fix. Every run of the script overwrites /var/backups/state.json with the last run timestamp, the last status, the last archive name, and the last archive size. If you can read that file, you know whether the backups are running, when they last ran, and what the last archive was called.I now have a separate tiny script (10 lines of bash) that runs in cron once a day and emails me if the state file is older than 26 hours. That is the "did it run last night" check. The state file plus that check is the actual feedback loop. Without them, the cron entry is just a hope.The three times it has saved meHere is the actual record. I keep this so I do not get talked out of running backups by future-Brian, who is sometimes tempted to skip the rotation cleanup to save five minutes.Time 1: the bad cert deploy. February 14, 2025. I was renewing TLS certs and accidentally overwrote a working config with a half-baked one. The site went down at 4pm on a Friday. I pulled the previous night's backup, diffed it against the broken config, and had the site back up in 18 minutes. The diff took 17 minutes. The actual restore took 45 seconds.Time 2: the disk full. July 3, 2025. A log file ran away on a host I do not check every day. The disk filled to 100%, and the cron job that night failed to write the tarball. The next morning, my "did it run last night" check fired. I logged in, found the runaway log file, freed 14GB, and re-ran the script by hand. The cron entry resumed that night. The cost was one missed backup. Without the alert, the cost would have been 30 missed backups by the time I noticed.Time 3: the wrong rm. November 18, 2025. I was cleaning up an old deploy directory and ran rm -rf jobs/ instead of rm -rf jobs-old/. The directory had six months of cron jobs and their logs. I noticed 20 minutes later. I pulled the previous night's backup, extracted just the jobs/ directory from the tarball, and was back in business. The six months of logs were gone for the 20 minutes, but the previous night's logs were there, which was all I actually needed.Each of those three times, the backup was a year-old Saturday-afternoon project. Each time, it paid for the time it took to build it many times over. The script is still the same. The cron entry is still the same. The config file is the same. The state file is the same. I have not had to touch any of it since November.What it does not doIt does not back up to a remote host. The backups live on the same disk as the source data. That is the failure mode I have not solved: if the disk dies, both the source and the backup die with it. The fix is a second host, an S3 bucket, a Backblaze B2 bucket, or a rsync to an offsite box. I have not done that fix yet, and the failure mode is real. The script is the on-host half. The off-host half is a v2 problem.It also does not back up the database. The databases I care about are on managed hosts (e.g. Hostinger for this site, a managed Postgres on Supabase for the side projects). The host's own backup system handles those. If you have a self-hosted database, add a pg_dump line to the script and add the dump file to the tarball.The honest partThe script is not magic. It is a Saturday-afternoon project, 80 lines of Python, one config file, one cron entry, one alert script. It has saved me three times in 18 months. The savings on time and stress are not small. The script costs me about five minutes of attention per year, which is the price of a daily cron and a quarterly glance at the log file.If you do not have a backup script yet, copy the one above, change the config, and put it in cron tonight. The first time it saves you will not be the time you expect it to. That is the part no one tells you: the value of a backup is not the average over a year. The value is concentrated in the one day a year when you really need it, and the question is whether that day is a recovery or a loss.Want a prompt pack to document the systems you build?The Blog Writing Factory includes a "release note" prompt that turns a finished script into a publish-ready post. Backup script to blog post in about 20 minutes. Useful when you want to share what you built.Buy on Gumroad. $14
Send me the rough edges if you try it. I read every message.