Backup plugins running inside WordPress are the same WordPress the attacker just compromised. UpdraftPlus, BackWPup, BlogVault, and every other in-dashboard backup tool runs with the same file permissions and database credentials as the malware. By the time you need the backup, the attacker has often already replaced the most recent archive, deleted the older ones, or modified the plugin to write empty files while reporting "Backup complete." The fix is to put the backup layer outside the trust boundary entirely: scheduled on the server, owned by a system user that WordPress never sees, writing to encrypted off-site storage that the WordPress process has no credentials to.
This article covers the working 3-2-1 setup I run on every WordPress site I touch in 2026. Two tool choices (restic and borg, depending on your destination), the cron schedule, the retention policy, and the monthly verification step that turns "we have backups" into "we have backups that restore." It is the companion to why your security plugin keeps getting silently disabled and server-side file integrity monitoring; the same trust-boundary principle applies to backups, monitoring, and integrity all at once.
I started insisting on off-server backups around 2018, after the third client in eighteen months told me their UpdraftPlus archives had been "corrupted" right when they needed them. In every one of those cases, the corruption was the attacker overwriting the archive with a zero-byte file from inside WordPress on the cron run before discovery. Once you see it once, you stop trusting any backup that lives where the site lives.
Set up the cleanup workspace
Set your site values once below and the commands in this article will pick them up. The downloadable scripts further down use the same variable names; you edit them at the top of the file before saving.
Set your site, paths, database name, and storage destination once. The commands below pick them up automatically. The downloadable scripts use the same names at the top of the file.
Why in-WordPress backup plugins fail under compromise
The mechanism is direct. UpdraftPlus and its peers run via WP-Cron or via the WordPress dashboard. Both execute as the same Unix user as the PHP-FPM pool, with read and write access to /wp-content/, the database, and (if configured) the storage destination's credentials stored in wp_options. A compromised site has all four of those at its disposal.
The four specific failure modes I see during cleanups:
-
Archive replacement. Attacker drops a tiny shell script as a must-use plugin (
mu-plugins/maint.php) that fires on the same hook as the backup plugin's completion event. The scriptrename()s the just-written archive into a zero-byte file. The plugin reports success. The dashboard says "Last backup: 2 hours ago, 1.2 MB." -
Credential theft. Backup destinations configured in the plugin (S3 keys, Dropbox tokens, Google Drive OAuth refresh tokens) live in
wp_optionsor in the plugin's serialized settings. An attacker with database access reads them, then deletes the off-site archives directly from the destination. -
Silent disable. Same pattern as security plugin disabling. The attacker SQL-deletes the backup plugin from the
active_pluginsoption. The plugin no longer runs. The dashboard shows the plugin as inactive, but most owners never check. -
Selective exclusion. Modified backup plugin code excludes the attacker's persistence files from the archive. Subsequent restores reintroduce a clean WordPress, but the attacker's
mu-plugins/loader.phpand the autoloadedwp_optionsrow are absent from the archive entirely. The owner restores, checks "is the site working," sees that it is, and never realises the persistence layer was never backed up.
In all four cases, the attacker controls both the live site and the backup. The 3-2-1 principle exists specifically to break that single point of trust.
If you don't have shell access (most shared and managed hosts)
Before the 3-2-1 section below, an honest caveat: the server-side restic + cron + S3 setup described in this article requires SSH access, the ability to install binaries (apt install restic), permission to add system cron jobs, and an account with a cloud storage provider like AWS S3, Backblaze B2, or rsync.net. A lot of WordPress users don't have any of those. Shared cPanel hosting often blocks SSH on lower tiers. Managed WordPress hosts (most of WP Engine's plans, Bluehost's lower tiers, GoDaddy Managed WordPress) deliberately don't give shell access. Even basic file system access via SFTP is often jailed to public_html/ so you can't run scripts from outside the WordPress directory anyway. And signing up for AWS or B2 is a separate process most site owners haven't done.
If that's you, the realistic backup options ranked from best to worst:
Option 1: Your host's snapshot service (use this first if available)
Most managed WordPress hosts (Kinsta, WP Engine, Pressable, Flywheel, Cloudways, SiteGround, Hostinger Premium) take nightly snapshots at the hypervisor layer, outside WordPress entirely. The snapshots are taken by the host's infrastructure, not by anything running inside your site, so an attacker with WordPress admin access cannot touch them. Most hosts retain 14-30 days. Restore is usually a one-click button in the dashboard.
Pros: free with your hosting plan, no setup, outside the WordPress trust boundary by design. Cons: tied to your host (a host-level breach affects backups too), restore is usually "the whole VM at a point in time" not granular, retention is usually 14-30 days which can be too short to predate a compromise.
If your host offers this, turn it on, set the retention as long as the plan allows, and verify by doing a test restore to a staging environment at least once.
Option 2: An off-server backup plugin with the credentials secured
Use a plugin that writes backups to a destination outside your hosting account, in roughly this order of preference:
- BlogVault (paid, ~$89/yr per site). Pushes backups to BlogVault's own off-server infrastructure via their plugin. Their architecture means the destination is not directly readable or deletable from your wp_options table because the API requires their server-side validation. Daily incremental, 90-day retention on the basic plan. The plugin can be deleted by an attacker but BlogVault's prior backups stay accessible from their dashboard, which you log into separately.
- Jetpack VaultPress Backup (paid, included with Jetpack Security or Complete, ~$120/yr). Same off-server-destination model, hosted by Automattic. Real-time backup, 30-day retention on Security, 1-year on Complete.
- UpdraftPlus (free + paid, ~$70/yr for Premium). Off-server destination configurable (S3, Dropbox, Google Drive, Backblaze B2, custom SFTP). Vulnerable to the credential-theft case described above (destination tokens are in
wp_options), but you can mitigate that with the steps below. - BackWPup (free + paid). Same architecture as UpdraftPlus, same caveats.
If you use UpdraftPlus or BackWPup with a cloud destination:
- Periodically download a copy to your local machine. Once a week, log in to the plugin's dashboard, download the most recent backup zip to your laptop, and store it somewhere safe (encrypted external drive, encrypted cloud sync, your password manager's secure notes for small databases). This local copy is the one step an attacker who has compromised WordPress absolutely cannot reach. It is the most important backup you have.
- Use a destination with versioning enabled. S3 versioning, Backblaze B2 versioning, and Google Cloud Storage object versioning all preserve overwritten objects. Even if the attacker reads your destination credentials and "deletes" the off-site archive, the previous version is still there and you can restore via the storage provider's UI.
- Use a separate cloud account just for backups. Don't use the same AWS / B2 / Google account your business runs on. A dedicated account with billing alerts and access only to one bucket is a much smaller blast radius if the credentials leak.
- Rotate the destination credentials quarterly. Tokens in
wp_optionsare exposed to anyone who reads the database. Treating them as "exposed by default" and rotating is the only defensive posture that makes sense.
Option 3: Manual exports as a baseline
If you have no plugin, no host snapshot, and no shell access, you can still:
- Use the WordPress
Tools → Exportfeature to export posts and pages as XML, periodically download. - Use phpMyAdmin (most shared hosts include it) to export the full database as
.sql.gz, download manually. - Use cPanel
File Manager → Compress → Downloadto grab the fullpublic_html/tree as a zip.
This is "better than nothing" rather than "good", because it's manual (gets skipped), it's slow (full exports of large sites can time out), and it lacks the cleanup-specific "predates the compromise" property you need during real recovery. But for hobby sites it's a reasonable floor.
Why everything in this section is still a compromise
Every option in this section has the same structural weakness: at some point in the chain, the WordPress site (or your WordPress dashboard credentials) has the access required to delete or corrupt the backup. The 3-2-1 setup that follows breaks that property entirely (the backup user is a different Unix user, the destination credentials are in /root/, the system cron is unreachable from PHP), but it requires server-level access. If you can't get server-level access on your current host and your site has any commercial value, migrate to a host that gives you SSH. A $5/month DigitalOcean droplet plus a managed WordPress install, or any of the higher-tier plans on Cloudways, WP Engine, or Kinsta, will give you the access this article assumes. The same hosting plan that gives you backups properly also gives you server-side file integrity monitoring, the hardened wp-config template, and the rest of the WordPress security stack that's not available on dashboard-only hosting.
The 3-2-1 rule, applied to WordPress
The traditional 3-2-1 backup formulation, originally from photographer Peter Krogh's 2005 book, is:
- 3 copies of the data
- on 2 different storage media (or systems)
- with 1 copy off-site
For WordPress in 2026, I translate this as:
- Primary: the live server (the WordPress install itself counts as copy #1)
- Secondary: a server-side encrypted snapshot held on a different volume or mounted block device, written by a system cron job that runs as
rootor a dedicated backup user, not as the PHP user - Tertiary: off-site encrypted backups in object storage (S3, Backblaze B2, Wasabi, or rsync.net), uploaded by the same root-level cron, with credentials stored in
/root/.config/restic/(or/root/.borg/), never anywhere PHP can read
The trust property: WordPress can read and write to copy #1. It cannot reach copies #2 or #3 even if fully compromised, because the credentials live outside the WordPress filesystem and the writes happen as a different OS user.
Choosing restic or borg
Both are mature, deduplicating, encrypted, snapshot-based backup tools written by people who clearly take adversarial scenarios seriously. The choice between them comes down to your storage destination.
| Property | restic | borg |
|---|---|---|
| Year started | 2014 | 2010 |
| Language | Go | Python (with C deps) |
| Destinations | S3, B2, GCS, Azure, SFTP, local, rclone | SSH, local, mounted |
| Native object storage | Yes | No (needs borgbackup-rsync.net or rclone tunnel) |
| Encryption | Always on, repokey + chacha20 | Always on, repokey + AES-CTR |
| Deduplication | Variable-size chunks (CDC) | Variable-size chunks (Buzhash) |
| Compression default | Off (configurable) | LZ4 |
| Mount snapshots as FS | Yes (restic mount) | Yes (borg mount) |
| Prune speed | Fast (sequential) | Fast (parallel since 1.2) |
| Best fit | Cloud object storage destinations | SSH to a backup server or local disk |
My default in 2026 is restic for object-storage destinations (S3, B2, Wasabi, rsync.net's restic mode) and borg when the destination is SSH to a dedicated backup server. If you only have one destination and it is cloud object storage, restic is the simpler choice.
The rest of this article uses restic examples because object-storage off-site is the more common setup for small-to-mid WordPress sites. The borg equivalents are noted inline where they diverge.
Setting up the dedicated backup user
The backup process runs as its own Unix user, not as the WordPress PHP user (www-data, nginx, etc.) and not as root for the read step. The principle: the backup user reads what it needs, and only root (or a more privileged user via sudo) writes the cron schedule.
# Create the backup user with no shell login
sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/lib/backup backup
sudo mkdir -p /var/lib/backup
sudo chown backup:backup /var/lib/backup
sudo chmod 700 /var/lib/backup
# Give read-only access to the WordPress directories
sudo setfacl -R -m u:backup:rX :wp_dir
sudo setfacl -dR -m u:backup:rX :wp_dir
# Database read access via a backup-only MySQL user
sudo mysql -e "CREATE USER 'backup'@'localhost' IDENTIFIED BY 'STRONG_RANDOM_HERE';
GRANT SELECT, LOCK TABLES, SHOW VIEW, EVENT, TRIGGER ON :db_name.* TO 'backup'@'localhost';
FLUSH PRIVILEGES;"ACLs grant backup read access to the live WordPress tree without writing to the underlying permission bits the web server depends on. The MySQL user has only what mysqldump needs, scoped to one database. No INSERT, UPDATE, DELETE, DROP. If the backup user is somehow compromised, it cannot modify either the files or the database.
Store the MySQL password in /root/.my-backup.cnf, owned by root, mode 600:
# /root/.my-backup.cnf
[client]
user=backup
password=STRONG_RANDOM_HEREThis file is not readable by backup (or anyone except root). The cron job that owns the backup runs as root and passes --defaults-extra-file=/root/.my-backup.cnf to mysqldump. Root invokes restic as the backup user via sudo -u backup for the file portion. Two privilege boundaries, neither of which WordPress can cross.
The backup script
A single shell script handles both halves: database dump and filesystem snapshot, then restic backup to off-site, then prune.
#!/usr/bin/env bash
# wp-backup.sh, nightly off-server WordPress backup via mysqldump + restic.
# Install at /usr/local/sbin/wp-backup.sh, run from root crontab.
# Source: https://techearl.com/wordpress-offsite-backups-3-2-1-with-verification
# Site: https://techearl.com/
set -euo pipefail
SITE="example.com"
WP_DIR="/var/www/${SITE}"
DB_NAME="wp_production"
DUMP_DIR="/var/lib/backup/dumps"
LOG="/var/log/wp-backup.log"
# restic destination + key
export RESTIC_REPOSITORY="s3:s3.us-east-1.amazonaws.com/example-backups/${SITE}"
export RESTIC_PASSWORD_FILE="/root/.restic.password"
export AWS_ACCESS_KEY_ID=$(cat /root/.restic.aws-key)
export AWS_SECRET_ACCESS_KEY=$(cat /root/.restic.aws-secret)
ts=$(date -u +'%Y-%m-%dT%H-%M-%SZ')
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*" | tee -a "$LOG"; }
# 1) Database dump (atomic, single-transaction for InnoDB)
mkdir -p "$DUMP_DIR"
chown backup:backup "$DUMP_DIR"
chmod 700 "$DUMP_DIR"
DUMP_FILE="${DUMP_DIR}/${DB_NAME}-${ts}.sql.gz"
log "dumping ${DB_NAME} to ${DUMP_FILE}"
mysqldump \
--defaults-extra-file=/root/.my-backup.cnf \
--single-transaction \
--quick \
--routines \
--triggers \
--events \
--set-gtid-purged=OFF \
--no-tablespaces \
"$DB_NAME" \
| gzip > "$DUMP_FILE"
# 2) restic backup of WordPress files + the fresh dump, run as backup user
log "restic backup starting"
sudo -u backup --preserve-env=RESTIC_REPOSITORY,RESTIC_PASSWORD_FILE,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY \
restic backup \
--tag "nightly" \
--tag "${SITE}" \
--host "${SITE}" \
--exclude "${WP_DIR}/wp-content/cache" \
--exclude "${WP_DIR}/wp-content/uploads/cache" \
--exclude "*.log" \
--exclude "*.tmp" \
"$WP_DIR" \
"$DUMP_DIR"
# 3) Prune policy (keep N daily, weekly, monthly, yearly)
log "restic forget + prune"
sudo -u backup --preserve-env=RESTIC_REPOSITORY,RESTIC_PASSWORD_FILE,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY \
restic forget \
--tag "nightly" \
--host "${SITE}" \
--keep-daily 14 \
--keep-weekly 8 \
--keep-monthly 12 \
--keep-yearly 5 \
--prune
# 4) Sanity-check by listing the latest snapshot
log "latest snapshots:"
sudo -u backup --preserve-env=RESTIC_REPOSITORY,RESTIC_PASSWORD_FILE,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY \
restic snapshots --tag "nightly" --last | tee -a "$LOG"
# 5) Clean up local dump (already in restic)
find "$DUMP_DIR" -name '*.sql.gz' -mtime +1 -delete
log "backup complete"Schedule via root's crontab:
# m h dom mon dow command
17 3 * * * /usr/local/sbin/wp-backup.sh > /dev/null 2>&1
The 17 3 * * * (3:17 AM daily) is intentionally off the hour; round numbers spike contention in shared environments.
The retention policy in the script keeps 14 daily, 8 weekly, 12 monthly, and 5 yearly snapshots. For a typical small WordPress site (200 MB of files, 50 MB of DB), this works out to roughly 1.5 GB of deduplicated restic storage, well under the free tiers of most cloud providers. Adjust upward for media-heavy sites, but be aware that uploads should generally be migrated to dedicated object storage anyway, not backed up with the application.
Encryption key handling
Restic encrypts the repository with a key derived from RESTIC_PASSWORD_FILE. The encryption is mandatory; there is no "unencrypted restic repo" option. Two things to get right:
-
The password file is not the only key. Restic's design is that the password unlocks a master key stored inside the repository. Adding additional passwords (via
restic key add) lets you have a recovery password held by a different person without rotating everything. I always add a second key held by someone other than the server admin (a co-founder, the client, a/root/RECOVERY.txtprinted and stored in a safe). -
The password file lives outside the backup. If the only copy of
/root/.restic.passwordis on the server you're backing up, and the server gets wiped, the backup is unreadable. Print it, store it in 1Password, email it to yourself, encrypt-and-commit to a private GitHub repo, whatever fits your operational model. Just not "only on the server."
The same principle applies to the AWS or B2 credentials. Lose the API keys, lose access to the repository. Store them somewhere recoverable.
Verification: monthly test restores
A backup that has not been restored is not a backup, it is the hope of a backup. The single most important practice in this whole setup is the monthly verified restore: pulling a real snapshot down to a scratch directory, restoring the database to a scratch MySQL database, and confirming the site boots cleanly.
I do this via a second script, run by hand on the first Sunday of every month:
#!/usr/bin/env bash
# wp-restore-test.sh, monthly verified-restore check for the off-server backup.
# Pulls the latest restic snapshot to a scratch dir, loads the SQL into a scratch
# database, runs basic sanity checks, then cleans up.
# Install at /usr/local/sbin/wp-restore-test.sh, run by hand or from cron.
# Source: https://techearl.com/wordpress-offsite-backups-3-2-1-with-verification
# Site: https://techearl.com/
set -euo pipefail
SITE="example.com"
SCRATCH="/var/lib/backup/restore-test"
DB_TEST="wp_restore_test"
export RESTIC_REPOSITORY="s3:s3.us-east-1.amazonaws.com/example-backups/${SITE}"
export RESTIC_PASSWORD_FILE="/root/.restic.password"
export AWS_ACCESS_KEY_ID=$(cat /root/.restic.aws-key)
export AWS_SECRET_ACCESS_KEY=$(cat /root/.restic.aws-secret)
mkdir -p "$SCRATCH"
rm -rf "${SCRATCH:?}/"*
# 1) Restore the most recent snapshot
echo "restoring latest snapshot to ${SCRATCH}..."
restic restore latest --target "$SCRATCH"
# 2) Find the database dump and load it into a scratch DB
DUMP=$(find "$SCRATCH" -name '*.sql.gz' | sort | tail -1)
echo "loading ${DUMP} into ${DB_TEST}..."
mysql --defaults-extra-file=/root/.my-restore.cnf -e "DROP DATABASE IF EXISTS ${DB_TEST}; CREATE DATABASE ${DB_TEST};"
zcat "$DUMP" | mysql --defaults-extra-file=/root/.my-restore.cnf "$DB_TEST"
# 3) Verify the restore by checking row counts and a known option
ROWS=$(mysql --defaults-extra-file=/root/.my-restore.cnf -BN -e "SELECT COUNT(*) FROM ${DB_TEST}.wp_posts;")
HOME_URL=$(mysql --defaults-extra-file=/root/.my-restore.cnf -BN -e "SELECT option_value FROM ${DB_TEST}.wp_options WHERE option_name='siteurl';")
echo "wp_posts row count: ${ROWS}"
echo "siteurl: ${HOME_URL}"
if [ "$ROWS" -lt 10 ]; then
echo "FAIL: wp_posts has fewer than 10 rows; restore is incomplete"
exit 1
fi
if [ -z "$HOME_URL" ]; then
echo "FAIL: siteurl is empty"
exit 1
fi
echo "PASS: restore verification complete"
# 4) Clean up
mysql --defaults-extra-file=/root/.my-restore.cnf -e "DROP DATABASE ${DB_TEST};"
rm -rf "${SCRATCH:?}/"*The wp_posts row count and the siteurl option are quick signals that the dump loaded correctly and represents a real site. For high-stakes sites I extend this to actually spin up a temporary WordPress on the scratch directory pointed at the scratch database, and curl http://127.0.0.1:port/wp-login.php to confirm 200 OK. The principle is the same: confirm the restore would actually work.
Schedule the verification in the calendar, not just in cron. The whole point is having a human acknowledge that the restore succeeded. If it runs from cron and silently passes for months while actually failing, the verification step has been defeated. Once a month, on the first Sunday, I open a checklist and confirm.
Restoring after a compromise (not a generic disaster)
There is an important distinction between restoring from disaster (the disk died, the server burned, the cloud region went down) and restoring after a compromise. The disaster recovery is "put the latest backup back where it was." The compromise recovery is more nuanced.
The danger in compromise restoration: the most recent backup probably contains the compromise. If you restore "the latest backup" you are restoring the attacker's persistence along with the legitimate content.
The sequence I follow:
-
Identify the entry-point date. Use the access-log forensics from the entry-point article to determine when the attacker first gained access. Call this date
T0. -
Pick a backup from before T0. Restic snapshots are immutable, and the deduplication means storage cost is low. A 12-month yearly retention is exactly for this scenario: it is normal for WordPress compromises to be discovered 3-6 months after initial access, and you need a clean reference point.
-
Diff the pre-T0 backup against the live site. This is the time-consuming step. Many of the changes between pre-T0 and the current site are legitimate (new posts, new uploads, new plugins, plugin auto-updates). Some are the compromise. The diff goes through each:
bash# Mount the pre-T0 snapshot as a filesystem mkdir -p /mnt/pre-compromise restic mount /mnt/pre-compromise & # Diff against the live site diff -rq /mnt/pre-compromise/restore/var/www/example.com /var/www/example.com \ | grep -v 'wp-content/cache' | grep -v 'wp-content/uploads/cache' -
Reconstruct the legitimate changes manually. New posts come from the WordPress dashboard (or by selectively importing from the post-compromise DB). New plugins come from fresh installs from wordpress.org, not from the backup. Files in
/wp-content/uploads/from after T0 get scrutinised one by one for.phpmasquerading as.jpgor similar. -
Restore the file tree from pre-T0, the database from pre-T0, then layer the legitimate post-T0 changes back in. This is slow and tedious. It is also the only sequence that produces a known-clean site without preserving the attacker's persistence.
I have a longer process document for this that I run through with clients during cleanup engagements. The 5-step summary above is the principle; the details depend on the compromise.
Where backup plugins still make sense
There are three scenarios where an in-WordPress backup plugin is the right answer rather than a compromise:
-
You don't have server-level access (the entire situation covered in the earlier section). If the host won't give you SSH and you can't migrate, an off-server-destination plugin like BlogVault, Jetpack VaultPress, or UpdraftPlus with versioning enabled on the destination is the best you can do. Pair it with weekly manual downloads to your local machine and you have something resembling a real backup. It is not as good as the server-side setup, but it is much better than nothing, and "nothing" is what most sites in this category have.
-
Migrations. Backup plugins are very good at packaging up a WordPress site into a portable archive that can be unpacked elsewhere with the URLs rewritten correctly. For migrations from one host to another, they save real time. Use them for the migration, run the migration, then delete the plugin or pair it with the server-side setup if your destination host supports it.
-
Belt-and-suspenders on top of the server-side setup. If you already have restic running nightly to off-server storage, and your host also offers daily snapshots, adding BlogVault on top isn't unreasonable. Three independent backup mechanisms with three different failure modes is more resilient than two. The cost is the plugin license and the small attack surface of running another plugin, which I think is worth it on commercial sites.
For everything outside those three cases, an in-WordPress backup plugin is providing the appearance of a backup without the trust property that makes a backup useful. The dashboard says "backed up." The reality is that anyone who can read wp_options can also delete every backup that plugin has ever taken from a destination configured inside WordPress. That is not a backup.
FAQ
See also
- Why WordPress Malware Keeps Coming Back: Persistence Mechanisms: the full catalog of places the compromise hides, so you know what to look for when diffing pre-compromise against live during a clean restore.
- Why Wordfence (or Any Security Plugin) Keeps Getting Silently Disabled: the same trust-boundary principle as backups, applied to monitoring.
- WordPress File Integrity Monitoring That Can't Be Disabled from Inside the Server: the read-only monitoring layer that pairs with off-server backups to give you both detection and recovery outside WordPress.
- How to Find the Original Entry Point in a WordPress Compromise: the forensics that establish the T0 date you restore from.
- A Hardened wp-config.php Template: the configuration baseline that goes into the post-restore site, not just the pre-compromise one.
- How to Remove WordPress Malware: the cleanup playbook this article is a defensive complement to.
External references: the restic documentation on backup integrity covers verification beyond what is in this article, and the 3-2-1 rule's origin is worth reading for the source material. For multi-server architectures, the borgbackup quickstart is the right starting point.
If you want help designing the backup layer for a multi-site WordPress operation, or testing the restore process under realistic conditions, get in touch. Off-server backups are one of the engineering practices that I find clients underinvest in until they need one and discover they do not have one that works.





