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
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/"
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
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
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
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
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-tablesinherited 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. Usewp 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)