TechEarl

Off-Server WordPress Backups (3-2-1) With Verified Restores

The backup plugin running inside WordPress is the same WordPress the attacker just compromised. A 3-2-1 backup strategy with restic or borg, stored outside the trust boundary, and verified by monthly test restores. Configuration, retention, and the exact restore sequence after a compromise.

Ishan KarunaratneIshan Karunaratne⏱️ 22 min readUpdated
Why UpdraftPlus and other in-WordPress backup plugins fail when the site is compromised, plus a working 3-2-1 setup with restic or borg, retention policy, and a verification routine.

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.

Try it with your own values

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:

  1. 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 script rename()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."

  2. Credential theft. Backup destinations configured in the plugin (S3 keys, Dropbox tokens, Google Drive OAuth refresh tokens) live in wp_options or in the plugin's serialized settings. An attacker with database access reads them, then deletes the off-site archives directly from the destination.

  3. Silent disable. Same pattern as security plugin disabling. The attacker SQL-deletes the backup plugin from the active_plugins option. The plugin no longer runs. The dashboard shows the plugin as inactive, but most owners never check.

  4. 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.php and the autoloaded wp_options row 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:

  1. 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.
  2. 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.
  3. 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.
  4. Rotate the destination credentials quarterly. Tokens in wp_options are 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 → Export feature 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 → Download to grab the full public_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:

  1. Primary: the live server (the WordPress install itself counts as copy #1)
  2. Secondary: a server-side encrypted snapshot held on a different volume or mounted block device, written by a system cron job that runs as root or a dedicated backup user, not as the PHP user
  3. 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.

Propertyresticborg
Year started20142010
LanguageGoPython (with C deps)
DestinationsS3, B2, GCS, Azure, SFTP, local, rcloneSSH, local, mounted
Native object storageYesNo (needs borgbackup-rsync.net or rclone tunnel)
EncryptionAlways on, repokey + chacha20Always on, repokey + AES-CTR
DeduplicationVariable-size chunks (CDC)Variable-size chunks (Buzhash)
Compression defaultOff (configurable)LZ4
Mount snapshots as FSYes (restic mount)Yes (borg mount)
Prune speedFast (sequential)Fast (parallel since 1.2)
Best fitCloud object storage destinationsSSH 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.

bash
# 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:

ini
# /root/.my-backup.cnf
[client]
user=backup
password=STRONG_RANDOM_HERE

This 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.

wp-backup.shNightly off-server WordPress backup: mysqldump + restic snapshot + retention prune.Download
bash
#!/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:

cron
# 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:

  1. 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.txt printed and stored in a safe).

  2. The password file lives outside the backup. If the only copy of /root/.restic.password is 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:

wp-restore-test.shMonthly verified-restore check: pulls latest restic snapshot to scratch and sanity-checks the DB.Download
bash
#!/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:

  1. 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.

  2. 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.

  3. 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'
  4. 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 .php masquerading as .jpg or similar.

  5. 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:

  1. 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.

  2. 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.

  3. 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

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.

TagsWordPressSecurityBackups3-2-1resticborgDisaster RecoveryIncident ResponseOff-site BackupVerified Restore
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years across software, Linux systems, DevOps, and infrastructure — and a more recent focus on AI. Currently Chief Technology Officer at a tech startup in the healthcare space.

Keep reading

Related posts

Step-by-step WordPress malware removal: identify the attack vector (files, database, .htaccess, wp-config), clean every layer, rotate credentials, and lock down to prevent reinfection. Cross-platform scripts for Linux and macOS.

How to Remove WordPress Malware: The Practitioner's Playbook

A step-by-step methodology for finding and removing malware from a compromised WordPress site, written by a Security+ certified engineer who's been cleaning sites since the early WordPress 2.x era. Covers every attack vector: file backdoors, database injections, .htaccess hijacks, wp-config tampering, and recurring reinfection. Originally written in 2016, updated regularly as new patterns emerge.