How to Build an Offsite Backup Rotation You Can Trust
An offsite backup rotation is a schedule that keeps several independent copies of your site files and databases in a location separate from the server that serves them, and retires old copies on a fixed rule. The point is not to have a backup; it is to have a copy you can restore from when the primary host, the account, or the machine itself is gone, and to know exactly which copy that will be before you need it.
Most people who lose data have backups. They lose because the backup lived on the same disk, was overwritten by the same bad deploy, or was never tested. A rotation you can trust fixes all three by making copies independent, versioned, and boring to maintain.
Decide the unit of backup and the retention rule first
Before you touch a tool, write down two things: what gets captured, and how long each capture survives. The unit is usually one archive per site per run, containing the document root, any config that is not secret, and a database dump. Keep secrets out of the archive or encrypt the archive as a whole; a backup that leaks credentials is worse than no backup.
Retention is where most schemes quietly fail. A common durable pattern is a grandfather-father-son rotation: keep a recent set of frequent copies, a smaller set of daily copies, and a long tail of weekly or monthly copies. The exact counts matter less than the rule being deterministic, so that any script or any human can compute which copies should exist on a given day. State it as a policy, for example "keep the last several hourly, the last several daily, the last several weekly, and one per month for a fixed horizon," and implement that policy in code rather than by hand.
Two properties make a rotation trustworthy. First, copies must be immutable once written, so a compromised server cannot delete or rewrite history in the offsite store. Second, the offsite copy must be able to survive the loss of the primary provider entirely, which means it lives under a different account or a different vendor than the site itself. If your site and your backups share a login, you have one failure domain, not two.
Build the archive and push it with a real command
The mechanics are simple enough to do with standard tools. Dump the database, tar the files, name the archive with a timestamp and a content hash, then upload it. Naming by timestamp gives you the version; naming with a hash lets you verify later that the bytes arrived intact.
#!/bin/sh
set -eu
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
ARCHIVE="site-$STAMP.tar.gz"
mysqldump --single-transaction --routines dbname > db.sql
tar -czf "$ARCHIVE" db.sql /var/www/site
sha256sum "$ARCHIVE" > "$ARCHIVE.sha256"
aws s3 cp "$ARCHIVE" "s3://my-offsite-bucket/db/$ARCHIVE" \
--endpoint-url https://s3.example-offsite.invalid
aws s3 cp "$ARCHIVE.sha256" "s3://my-offsite-bucket/db/$ARCHIVE.sha256" \
--endpoint-url https://s3.example-offsite.invalid
rm -f db.sql "$ARCHIVE" "$ARCHIVE.sha256"
The --single-transaction flag on mysqldump keeps the dump consistent without locking writes on transactional tables. The sha256sum line is not decoration: it is the thing that lets you prove, months later, that the object in the bucket is the object you uploaded. Replace the endpoint with whatever your offsite store documents, and note that the bucket here is a placeholder you should point at your own account.
For the file half, a tool that speaks the same protocol with incremental support is often better than re-uploading a full tarball every run. If your offsite store is S3-compatible, restic or borg will deduplicate and encrypt, which keeps storage growth sane over years. The command shape is the same idea: initialise a repository once, then back up with a tag that encodes the rotation slot.
restic -r s3:https://s3.example-offsite.invalid/my-repo init
restic -r s3:https://s3.example-offsite.invalid/my-repo backup \
/var/www/site --tag daily
restic -r s3:https://s3.example-offsite.invalid/my-repo forget \
--keep-hourly 24 --keep-daily 14 --keep-weekly 8 --keep-monthly 12
The forget flags are the rotation rule made executable. Read them as the policy you wrote earlier: the numbers are yours to choose, but they should be decided once and left alone. Run snapshots after a few cycles to confirm the schedule is producing what you expect, and run check on a timer to detect corruption in the repository.
Make the offsite copy immutable and verifiable
Immutability is the property that separates a real offsite backup from a mirror. On an object store this usually means enabling object lock in compliance or governance mode with a retention period, so that even an administrator with the write key cannot delete an object before it expires. Some stores also let you version a bucket, which keeps overwritten objects recoverable without a separate tool. Whichever you use, the retention period on the store should be at least as long as your policy's tail, or the store will silently discard copies your rule expects to exist.
Verification is the other half. A copy is not a backup until you have restored from it. Schedule a restore drill on a fixed cadence, into a throwaway directory or a scratch host, and check three things: the archive extracts, the database dump loads, and the site actually runs. Record the result somewhere a human will read it. A drill that fails and is noticed is a good day; a drill that is never run is a story you tell after the outage.
Access control deserves the same care. Give the backup job a credential that can write new objects and cannot delete or overwrite old ones, and keep the delete permission on a separate credential that a human uses only when pruning. That single split removes the most common way a rotation destroys itself: a compromised server using the backup key to wipe the offsite store before you notice.
Run it on a schedule and keep it running
Put the job under a scheduler you already monitor, and make it noisy on failure. A cron entry that mails on error is enough if someone reads the mail; a wrapper that posts to a channel you actually watch is better. Emit a heartbeat on success, and alert when the heartbeat stops, because a rotation that silently stops running is indistinguishable from one that works until the day you need it.
# failure and silence are both alerts
0 2 * * * /usr/local/bin/backup.sh || /usr/local/bin/alert.sh "backup failed"
0 9 * * * /usr/local/bin/check-heartbeat.sh
Keep the policy and the scripts in version control alongside the site, so the rotation is documented by the thing that runs it. When you change the retention numbers, change them in one place and let the scheduler pick up the change. Review the whole scheme once a year against a simple question: if the primary host vanished this afternoon, which exact object would I restore from, and have I proven it loads?
What to do next: pick one site, write the retention policy as a sentence, and set up a single offsite destination under a separate account with write-only credentials. Run one backup, restore it into a scratch directory, and confirm the site loads. Only after that first round trip works should you add the schedule, the immutability lock, and the alerting, because a rotation you have restored from once is worth more than a clever one you have never tested.
