Why Backups Matter – Real-World Context & Developer Perspective

Last month a boutique coffee shop in Austin added a new WooCommerce gateway. The plugin hit an old php.ini flag, threw a fatal error, and the storefront vanished. The owner had no recent restore point. In 48 hours the shop lost about $12 000 and a few loyal customers.
That's not a fluke. In 2024 the WordPress Vulnerability Database logged over 1 800 critical exploits. Most get patched within days, but the damage is done the moment a rogue update or a hacker corrupts the file system or the database. And it isn't just WordPress. A bad npm install can wipe a static site's _site folder in seconds.
Backups are a safety net, sure. But a net never tested is just rope. I've seen S3 buckets holding archives that were never touched; when a restore was finally attempted the file was corrupted because a lifecycle rule had deleted the last full dump. The lesson? Your backup is only as good as the last successful restore test.
Think of a backup like a fire extinguisher. You don't buy one and hope it works; you pull the pin, spray a little, make sure the pressure holds. The same principle applies here. A quick restore drill saves you from months of downtime, lost revenue, and a bruised reputation.
Site Architecture & Technical Prerequisites

WordPress and static sites speak different languages, so your backup strategy has to match the architecture.
WordPress is a PHP app backed by MySQL/MariaDB. You need:
- SSH or SFTP to the web root.
- DB admin rights (a user that can
SELECTandLOCK TABLES). - Enough disk space for a full
wp-contentdump and the SQL export. - PHP 7.4+ and proper permissions (usually
755dirs,644files). - A cron daemon or scheduled task runner – you'll be scheduling nightly backups.
Static sites are just files: HTML, CSS, JS, images. The requirements shrink to:
- SSH or FTP to the build output folder (e.g.,
public/ordist/). - A place to stash the archive – S3, Google Drive, a local NAS, or even a cheap Wasabi bucket.
- Optional: a CI/CD runner if you want to trigger a backup after each build.
Common storage options:
- Amazon S3 – cheap, versioned, lifecycle rules.
- Google Drive – easy for small teams, but watch API quotas.
- Local NAS – fastest restore, but you need to protect it from the same disaster.
Toolbox you'll reach for daily:
rsync– incremental file sync, perfect for static assets.WP-CLI–wp db export,wp media export,wp core download.mysqldump– raw SQL dump if you prefer not to use WP-CLI.gpg– encrypt archives before they leave the server.
Make sure cron is enabled (crontab -l should list something) and that your PHP version matches the site's requirements. Mismatched versions can cause silent failures during a restore.
Step-by-Step Implementation: Core Setup
Below is a minimal, production-ready backup routine you can drop into /etc/cron.d/website-backup. Adjust paths and bucket names.
# WordPress nightly backup – runs at 02:30 AM
30 2 * * * www-data /usr/local/bin/wp-backup.sh >> /var/log/wp-backup.log 2>&1
wp-backup.sh – keep it owned by www-data and chmod 750:
#!/bin/bash
set -euo pipefail
# Config
WP_PATH="/var/www/html"
BACKUP_DIR="/backups"
DATE=$(date +%Y%m%d%H%M)
ARCHIVE="${BACKUP_DIR}/wp-${DATE}.tar.gz"
DB_DUMP="${BACKUP_DIR}/db-${DATE}.sql"
# 1. Export DB
cd "$WP_PATH"
wp db export "$DB_DUMP" --add-drop-table
# 2. Export media (optional, WP 5.9+)
wp media export --path="$WP_PATH" --output="${BACKUP_DIR}/media-${DATE}.zip"
# 3. Pull core files – not needed if version-controlled
wp core download --path="$WP_PATH" --force
# 4. Archive wp-content + DB + media
tar -czf "$ARCHIVE" -C "$WP_PATH" wp-content "$DB_DUMP" "media-${DATE}.zip"
# 5. Push to S3 (awscli must be configured)
aws s3 cp "$ARCHIVE" "s3://my-site-backups/wp/${ARCHIVE##*/}" --storage-class STANDARD_IA
# 6. Cleanup old files – keep 30 days
find "$BACKUP_DIR" -type f -mtime +30 -delete
For a static site the script shrinks to a single rsync plus zip step:
#!/bin/bash
set -euo pipefail
SRC="/var/www/static-site/public"
DEST="/backups/static"
DATE=$(date +%Y%m%d%H%M)
ARCHIVE="${DEST}/static-${DATE}.zip"
# Sync to a backup bucket (optional)
rsync -avz --delete "$SRC/" "$DEST/live/"
# Archive
zip -r "$ARCHIVE" "$SRC"
# Upload
aws s3 cp "$ARCHIVE" "s3://my-static-backups/${ARCHIVE##*/}"
Now the restore side. Keep a tiny script handy; you'll only run it in a staging environment or during a drill.
# restore-wp.sh
#!/bin/bash
set -euo pipefail
BACKUP_BUCKET="s3://my-site-backups/wp"
LATEST=$(aws s3 ls "$BACKUP_BUCKET/" | sort | tail -n1 | awk '{print $4}')
TMP_DIR="/tmp/restore-$(date +%s)"
mkdir -p "$TMP_DIR"
# Pull latest archive
aws s3 cp "${BACKUP_BUCKET}/${LATEST}" "$TMP_DIR/backup.tar.gz"
# Extract
tar -xzf "$TMP_DIR/backup.tar.gz" -C "$TMP_DIR"
# Restore DB
wp db import "${TMP_DIR}/db-*.sql" --path="/var/www/html"
# Restore files
rsync -av "$TMP_DIR/wp-content/" "/var/www/html/wp-content/"
# Optional: reinstall core if needed
wp core download --path="/var/www/html" --force
echo "Restore complete. Verify the site."
Run the restore script on a clone, not on live, until you're comfortable. The moment you can spin up a fresh VM, pull the archive, and have the site humming, you've passed the website backup restore test.
Advanced Configuration & Performance Tuning
Full backups every night are safe but wasteful. Switch to incremental snapshots after the first full dump.
With rsync you can add --link-dest to hard-link unchanged files, turning each backup into a near-zero-cost delta; for MySQL, mysqldump --single-transaction plus binary logs let you replay only the changes.
Compression matters. tar -I 'pigz -9' gives you multi-core gzip (up to 8× faster than plain gzip) while keeping compatibility. If bandwidth is tight, drop -9 to -5 – you'll shave seconds off the upload.
Retention policies keep storage tidy. A common pattern:
- Daily for 7 days.
- Weekly for 4 weeks.
- Monthly for 6 months.
Implement it with a simple find pipeline or let S3 lifecycle rules do the heavy lifting.
After a restore you'll want the site fast again. Flush any opcode cache (wp cache flush) and purge CDN edges. If you run WP-Rocket, call wp rocket clean --all. For Cloudflare, a quick API call clears the cache:
curl -X POST "https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/purge_cache" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
Security tip: encrypt every archive with GPG before it leaves the server.
gpg --symmetric --cipher-algo AES256 "$ARCHIVE"
rm "$ARCHIVE"
aws s3 cp "${ARCHIVE}.gpg" "s3://my-site-backups/wp/"
Store the passphrase in a vault (HashiCorp Vault, AWS Secrets Manager) and rotate the key every 90 days. If a bucket is compromised, the attacker still needs the GPG key.
Large sites can choke on a single massive upload. Throttle with aws s3 cp --expected-size or use --multipart-chunk-size-mb 100 to split the file into 100 MB parts. Parallel uploads keep the wall-clock time low while respecting your ISP's rate limits.
Finally, automate the test. Schedule a weekly job that pulls the latest backup to a disposable VM, runs the restore script, and sends a Slack webhook on success or failure. If the test fails, you'll know before a real disaster strikes.
Critical Production Pitfalls & Diagnostic Gotchas
Even a flawless backup script can implode once it hits production. Below are the nasty failures we keep seeing on live WordPress or static sites, plus the quick sanity checks that tell you what went wrong.
Permission errors. Your cron user can read /var/www but not /var/www/wp-content/uploads. The job finishes, yet the media folder vanishes from the archive. Run ls -lR /var/www/wp-content/uploads | grep "^-" | wc -l – you'll get zero. Fix it with chmod 750 or add the user to the web-group.
Database lock during export. An automated import may hold a lock; mysqldump then exits with code 1, leaving a truncated .sql. After the run, check echo $? or grep "error" in /var/log/mysql/error.log. Use mysqldump --single-transaction to avoid InnoDB table locks.
Missing.htaccess. Static generators often copy a template .htaccess. If your script runs non-interactively, a careless tar -czf backup.tar.gz * will skip hidden files. Verify with tar -tzf backup.tar.gz | grep.htaccess. If it's gone, add --exclude='!*.htaccess' or explicitly include it.
Broken symlinks. Many WordPress installs symlink wp-content/uploads to a separate volume. A naïve cp -r copies the link path, not the files, and a restore points at nowhere. Use tar --dereference for real files, or preserve the link with tar -czhf and recreate the mount on restore.
Time-drift on cron. Server clock jumps (NTP hiccup, DST shift) and "daily at 02:00" fires twice or skips entirely. Filenames clash, one overwrite follows. After each run, log date +%F_%T to a status file; compare with the previous entry. If the delta exceeds 24 h, raise an alert.
And a final word: never start a backup while a large upload is in progress. The upload holds an open file handle; the backup captures a half-written file, corrupting images. Pause uploads or schedule the backup during off-peak windows.
Comparison & Decision Matrix
Pick the approach that fits your budget, skill set, and recovery speed. Below is a ready-to-paste HTML table you can drop into the post.
| Solution | Setup time | Cost | Incremental support | Restore speed | Platform lock-in |
|---|---|---|---|---|---|
| WP-CLI + cron | ≈ 30 min (script + crontab) | Free (hosting-included) | Yes (rsync-based diffs) | Fast – local copy | None – pure WordPress |
| Managed backup plugin (UpdraftPlus) | ≈ 10 min (install + API key) | Free tier / $70 yr Pro | Yes (cloud-side deltas) | Medium – remote download | Vendor-specific UI & storage |
| Cloud-native script (AWS Lambda + S3) | ≈ 2 h (IAM, Lambda, S3 lifecycle) | Pay-as-you-go (≈ $0.02/GB mo) | Yes (S3 versioning) | Variable – Lambda cold start + S3 transfer | AWS ecosystem |
If you're a solo dev on a shoestring, the WP-CLI route gives you full control. Teams already paying for UpdraftPlus will love the UI and off-site storage. And if you live in AWS, the Lambda script scales without another SaaS subscription.
FAQ – High-Intent Technical Questions
How do I back up a multisite network?
Treat the network as a collection of independent sites. Dump the global tables first:
wp db export --path=/var/www/multisite --network
Then loop through each site ID:
for id in $(wp site list --field=blog_id --path=/var/www/multisite); do
wp db export "db-site-${id}.sql" --url=$(wp site url $id --path=/var/www/multisite)
tar -czf "files-site-${id}.tar.gz" -C /var/www/multisite/wp-content/uploads/${id}.
done
Store the global dump with the per-site archives. When restoring, import the global dump, recreate each site with wp site create, then import its DB and files.
Can I restore a WordPress backup to a different domain?
Absolutely. After importing the .sql, run a search-replace on the old URL:
wp search-replace 'oldsite.com' 'newsite.net' --skip-columns=guid --all-tables
Make sure WP_HOME and WP_SITEURL in wp-config.php point at the new domain. Media URLs in post meta get updated by the same command.
What's the safest way to test a restore without affecting live traffic?
Spin up a clone on a staging subdomain or a temporary VM. Copy the backup there, run the same restore script, then probe the site with a headless request:
curl -I https://staging.example.com | grep HTTP
If you see a 200, run a quick smoke test: wp core is-installed, wp plugin list --status=active, and a handful of front-end page loads. Block search bots with a robots.txt to keep the staging environment out of indexes.
How do I automate verification of backup integrity?
Two steps: checksum and DB sanity. After the archive lands, generate a SHA-256 hash:
sha256sum backup-$(date +%F).tar.gz > backup-$(date +%F).sha256
On the next run, compare:
sha256sum -c backup-$(date -d "yesterday" +%F).sha256
For the DB, run wp db check after import. If it returns "OK", you can trust the restore. Hook both commands into your cron and pipe failures to our support line.
Is there a way to back up only changed files without a full tar each night?
Yes. Use rsync --link-dest to create hard-linked snapshots. The first run makes a full copy; later runs add only changed files as new inodes.
SNAP=$(date +%Y%m%d)
rsync -a --delete --link-dest=/backups/last /var/www/ /backups/${SNAP}/
ln -sfn ${SNAP} /backups/last
This keeps storage lean and shrinks the nightly window dramatically.
Operational Verification Checklist & Next Steps
- Confirm the backup archive exists where you expect it – bucket or local folder.
- Validate the checksum (SHA-256 or MD5) against the stored hash.
- Deploy the archive to a staging environment that mirrors production.
- Run the restore script and watch exit codes; any non-zero triggers an alert.
- Open the front-end in a browser. Verify the homepage, a product page, and a blog post load without 404s.
- Log in to
/wp-admin(or your static CMS admin) and perform a quick edit – publish a draft, tweak a setting. - Record total time from start of restore to successful admin login. Note any bottlenecks.
If all steps pass, you've proved the backup is usable. Run these drills quarterly, and tweak your scripts whenever you add a plugin or change the stack. Need a hand building a custom Lambda backup? Check out our Web Engineering service page or drop us a line at Contact. Happy restoring!