DEV Community

Cover image for WordPress Backup and Restore Verification: What to Include, How Often, and How to Prove It Works
AumCreate
AumCreate

Posted on

WordPress Backup and Restore Verification: What to Include, How Often, and How to Prove It Works

Most backup conversations stop at "we take a nightly backup". That sentence describes a cron job, not a recovery plan. Until you have restored an artifact and checked the result, you have a hypothesis.

Here is the process, in the order it should be written down.

1. A backup is three artifacts, not one

Artifact Contents Why it is separate
Database dump Every table with the site prefix, plus any table a plugin created outside it Small, changes constantly, needs frequent capture
Files archive wp-content: uploads, themes, plugins, mu-plugins Large, changes rarely, needs versioning not frequency
Config + manifest wp-config.php, URLs, prefix, table count, active plugin list Needed to know what "complete" means

What must be in the dump: all prefixed tables and anything else the site writes to. Excluding tables you do not recognise is how restores lose order and membership data.

What must not be in the archive: wp-content/cache, wp-content/upgrade, wp-content/backups, log directories. They inflate the artifact and slow the restore for zero recovery value.

wp-config.php holds salts, database credentials, and sometimes hardcoded WP_HOME / WP_SITEURL. Store it separately and encrypt it.

The manifest is the part people skip. Record: UTC timestamp, siteurl, home, core version as reported by the install, table prefix, table count, active plugins. Without it you cannot answer "is this restore complete?" — only "does it load?".

2. Files and database must come from the same window

A dump taken at 02:00 and a tar taken at 02:40 can disagree: an attachment row exists for a file that is not in the archive, or the reverse. On a quiet blog nobody notices. On a store it is a missing product image during a campaign.

For anything taking orders, wrap the job:

wp --path="$SITE" maintenance-mode activate
trap 'wp --path="$SITE" maintenance-mode deactivate' EXIT
Enter fullscreen mode Exit fullscreen mode

Or snapshot at the storage layer, which is atomic and cheaper. Pick one approach per site; do not mix them.

3. Frequency is an RPO decision

Decide how much data loss is acceptable, then derive the schedule.

Site type Database Files Retention example
Brochure / blog daily weekly 14 daily, 8 weekly, 6 monthly
Store every 4–6 hours daily 48 hourly, 14 daily, 8 weekly
Membership / LMS every 4–6 hours daily same as store

Two event-driven runs matter more than the schedule: immediately before any core, plugin or theme update, and before any DNS, SSL or hosting migration. Those are the moments you need an artifact from ten minutes ago, not from last night. A third: before a bulk import or a search-replace.

4. Write, seal, and ship

#!/usr/bin/env bash
set -euo pipefail

SITE="/var/www/example.com"
DEST="/srv/backups/example.com"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
WORK="$DEST/$STAMP"
mkdir -p "$WORK"

# 1. database
wp --path="$SITE" db export "$WORK/db.sql" --single-transaction --default-character-set=utf8mb4

# 2. files (cache and old backups excluded)
tar -C "$SITE" -czf "$WORK/wp-content.tar.gz" \
  --exclude='wp-content/cache' \
  --exclude='wp-content/uploads/cache' \
  --exclude='wp-content/upgrade' \
  --exclude='wp-content/backups' \
  wp-content

# 3. config
cp "$SITE/wp-config.php" "$WORK/wp-config.php"

# 4. manifest
PREFIX="$(wp --path="$SITE" config get table_prefix)"
TABLES="$(wp --path="$SITE" db query \
  "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE();" \
  --skip-column-names)"
{
  echo "created_utc=$STAMP"
  echo "site_url=$(wp --path="$SITE" option get siteurl)"
  echo "home=$(wp --path="$SITE" option get home)"
  echo "core=$(wp --path="$SITE" core version)"
  echo "db_prefix=$PREFIX"
  echo "tables=$TABLES"
} > "$WORK/manifest.txt"

# 5. integrity checks at write time
gzip -t "$WORK/wp-content.tar.gz"
DUMPED="$(grep -c '^CREATE TABLE' "$WORK/db.sql")"
EXPECTED="$(grep -oE '^tables=[0-9]+' "$WORK/manifest.txt" | cut -d= -f2)"
if [ "$DUMPED" != "$EXPECTED" ]; then
  echo "FATAL: table count $DUMPED != expected $EXPECTED" >&2
  exit 1
fi

# 6. seal
( cd "$WORK" && sha256sum db.sql wp-content.tar.gz wp-config.php manifest.txt > SHA256SUMS )

# 7. ship offsite (adjust to your object storage CLI)
# aws s3 cp --recursive "$WORK" "s3://example-backups/$STAMP/"
Enter fullscreen mode Exit fullscreen mode

The check that catches the most real failures is not gzip -t — it is the table count. A dump that hit a timeout mid-postmeta is still a valid gzip file and will still import; it is just short. Compare the dump size against the median of the last seven runs and alert on a large negative deviation.

5. Verify the restore, not the backup

Once a month, and after any major change, restore the newest artifact into staging and run a checklist. Time the drill — that number is your real RTO.

Prerequisite: a staging install with its own wp-config.php pointing at a scratch database. Do not copy the production config into staging; it holds production credentials and possibly hardcoded URLs.

#!/usr/bin/env bash
set -euo pipefail

BACKUP="/srv/backups/example.com/20261020T020000Z"
STAGING="/srv/staging/example.com"
SRC_URL="https://example.com"
DST_URL="https://staging.example.com"

# 0. verify the seal before touching anything
( cd "$BACKUP" && sha256sum -c SHA256SUMS )

# 1. files
rm -rf "$STAGING/wp-content"
tar -C "$STAGING" -xzf "$BACKUP/wp-content.tar.gz"

# 2. database
wp --path="$STAGING" db drop --yes 2>/dev/null || true
wp --path="$STAGING" db create
wp --path="$STAGING" db import "$BACKUP/db.sql"

# 3. rewrite URLs (handles serialized options; sed on the dump does not)
wp --path="$STAGING" search-replace "$SRC_URL" "$DST_URL" --all-tables --precise

# 4. caches and rewrite rules
wp --path="$STAGING" cache flush || true
wp --path="$STAGING" rewrite flush --hard || true
Enter fullscreen mode Exit fullscreen mode

Then assert the content actually arrived. Row counts are the cheapest strong signal:

PREFIX=wp_
SRC="/var/www/example.com"
DST="/srv/staging/example.com"

for T in posts postmeta term_relationships terms term_taxonomy; do
  A="$(wp --path="$SRC" db query "SELECT COUNT(*) FROM ${PREFIX}${T}" --skip-column-names)"
  B="$(wp --path="$DST" db query "SELECT COUNT(*) FROM ${PREFIX}${T}" --skip-column-names)"
  [ "$A" = "$B" ] && S=OK || S=MISMATCH
  printf '%-18s prod=%-8s restored=%-8s %s\n' "$T" "$A" "$B" "$S"
 done
Enter fullscreen mode Exit fullscreen mode

Skip options and users in the first pass: staging plugins can legitimately write rows on load, and a mismatch there sends you chasing a non-problem. Compare posts, postmeta, term_relationships and your order tables first.

Uploads:

diff <(cd "$SRC/wp-content/uploads" && find . -type f | sort) \
     <(cd "$DST/wp-content/uploads" && find . -type f | sort) | head -20
Enter fullscreen mode Exit fullscreen mode

Empty output means the file lists match.

HTTP smoke test:

for p in / /sample-page/ /wp-login.php /robots.txt; do
  printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "https://staging.example.com$p")" "$p"
done
Enter fullscreen mode Exit fullscreen mode

6. The manual checklist

Automation will not tell you whether checkout works.

Check Pass condition
Admin login dashboard loads, no database error
Permalinks a post URL returns 200, not 404 (wp rewrite flush --hard if not)
Media an existing image renders and the file is on disk
Forms submit a test entry, confirm it is stored
Checkout test-mode order completes, order note and confirmation mail arrive
Cron wp cron event list shows the expected events
Outbound mail a test message arrives
File ownership web server can write to wp-content/uploads

That last row is the one that bites six weeks later, when a customer tries to upload a file and nobody can reproduce it.

7. Failure modes that survive a naive check

  • Truncated dump. Valid gzip, valid import, missing rows. Caught by the table count and a size-band alert.
  • --exclude-tables inherited from an old runbook, silently dropping a plugin's tables.
  • Uploads outside wp-content, because a host moved them. The archive is complete and contains no images.
  • Serialized data edited with sed. Use wp search-replace --precise, never text substitution on the dump.
  • Cron lost after a partial table restore, so scheduled posts and cleanup silently stop.
  • The backup stored inside wp-content, so tonight's job archives last night's job.
  • Wrong ownership after restore, so writes fail without an error the browser sees.

8. Log every drill

One line per drill: date, artifact timestamp, restore duration, row-count result, smoke test result, who ran it. When something breaks at 3am, that log is the difference between a runbook and a guess.

If you want this runbook written into the handover of a build rather than bolted on afterwards, backup and restore procedure is part of our custom WordPress work at AumCreate — custom development starts at $1,500, and the setup option covers deployment on a five-working-day turnaround.

Top comments (0)