From WordPress to Astro, Part 2: Auditing the WordPress Backup

How I checked a multipart WPvivid backup, compared it with REST and WXR, and built a safe inventory before migrating any WordPress content.

Series: From WordPress to Astro — Part 2 of 5

Before writing an importer, I needed to know which WordPress source I could trust. A public REST response, a WordPress export and a full backup can all contain posts, but they do not contain the same data and they are not interchangeable.

This mattered more for vEducate because I had access to wp-admin, but not SSH, the hosting control panel or the database server. I could inspect the live site, call the public API, use WordPress export tools and run an installed backup plugin. I could not simply connect to MySQL and copy the uploads directory from the server.

The eventual source was a six-part WPvivid backup containing a coherent database and filesystem snapshot. I did not assume it was usable just because all six download buttons had completed. I recorded the files, generated checksums, tested every ZIP, checked archive paths and built an inventory before any data was allowed near the Git repository.

This part explains that decision and gives you a reusable way to audit a similar backup.

Series contents

  1. From WordPress to Astro, Part 1: Choosing the New Platform
  2. From WordPress to Astro, Part 2: Auditing the WordPress Backup (you are here)
  3. From WordPress to Astro, Part 3: Converting Content, Media and URLs
  4. From WordPress to Astro, Part 4: Proving the Site Before Cutover
  5. From WordPress to Astro, Part 5: Cutting Over Cloudflare Pages and Cleaning Up

The wp-admin-only constraint

Limited hosting access changes how you recover a WordPress site, but it does not mean the public pages are your only source.

I had four realistic options:

  1. Read public content through the WordPress REST API.
  2. Authenticate to the API and request edit context.
  3. Download a WordPress eXtended RSS (WXR) file from Tools > Export.
  4. Use the installed WPvivid plugin to create and download a complete backup.

The important question was not “which option is easiest to parse?” It was “which source lets me account for all required public content while safely excluding private and operational data?”

What the REST API gives you

The WordPress REST API was useful for discovery. Public post responses included the post ID, date, slug, status, link, rendered title and content, author, featured-media ID, categories and tags.

That was enough to count public resources and compare visible URLs later. It was not a complete migration source.

The public API returned rendered post content rather than the unmodified editor value. Rendered HTML can already contain shortcode output, plugin placeholders and theme-related transformations. Public endpoints also omit drafts and private records, and they only expose metadata that WordPress or a plugin deliberately registered with the API.

Authenticated context=edit access can add raw content and drafts for an authorised user. It still does not automatically expose arbitrary plugin tables, unregistered metadata or the files under wp-content/uploads. Media endpoints describe attachments; they do not provide an auditable copy of every referenced file.

For this migration, REST became a live cross-check rather than the authority.

What a WXR export gives you

The normal WordPress export creates a WXR file: an XML document containing posts, pages, terms, comments and selected metadata. It is portable and well suited to moving content into another WordPress installation.

WXR would have been useful if a database backup was unavailable. It still needs separate handling for media and plugin-owned data, and it can contain personal data. The complete backup had also captured the database and files together; creating a WXR later would produce a second snapshot that needed reconciliation.

Once the full backup passed its checks, downloading WXR would add another sensitive source without filling a gap. I kept it as a fallback, not a required step.

Why the WPvivid backup became authoritative

The WPvivid backup held the broadest, same-time view of the site:

  • the database;
  • the uploads library;
  • themes and plugins;
  • other wp-content data; and
  • the WordPress core payload and backup manifests.

Not all of those files belong in the new site. The point is that the source was complete enough for the migration to make explicit choices.

The database let the importer select approved comments, inspect old slugs, reconstruct taxonomies and read plugin-owned redirect data plus Visualizer custom-post and metadata records. The uploads archives supplied the media referenced by posts.

The backup also contained data that must never be published: users, password hashes, private commenter fields, security logs, analytics records, plugin settings and potentially credentials. A full backup is more useful precisely because it is more sensitive. It belongs in a protected working area, not in the website repository.

The comparison looked like this:

Source Useful for Important gaps Role in this migration
Public REST Public inventory and live URL checks No raw content, drafts, arbitrary plugin data or media filesystem Cross-check
Authenticated REST Raw content and non-public records permitted to the user Still dependent on registered schemas and custom endpoints Not required
WXR export Portable WordPress content, terms and comments Not a full database, plugin or filesystem snapshot Fallback
WPvivid full backup Coherent database, uploads and implementation reference Highly sensitive and requires controlled extraction Authoritative source

Record exact software versions in the protected migration inventory while wp-admin and the backup manifest are still available. This snapshot captured the WordPress, PHP and database provenance, but not the backup-plugin release. That is a limitation: the archive shape and tested parser behaviour remain useful reproducibility evidence, but they cannot establish a plugin-version match that was never recorded. Point-in-time runtime values age quickly and are unnecessary in the public write-up; keep them with the private source evidence instead.

Understand multipart WPvivid backups

The vEducate download consisted of six outer ZIP files totalling about 912 MiB. Despite their part001 to part006 names, they were independent ZIP containers. They did not need to be concatenated.

Each outer ZIP held a manifest and one or more child archives. Across the six files, the child payloads included one database archive, a theme archive, a plugin archive, five upload archives, a general content archive and a WordPress core archive.

Six independent WPvivid ZIP files yielding database and upload importer inputs, a theme archive and four named plugins for private reference, and retained content and WordPress core archives.
After integrity and path checks, the database and uploads become importer inputs. The theme archive, containing two themes, and four named plugins were extracted only for private implementation reference; the remaining payloads stayed archived. Open the full-size diagram.

Joining independent ZIPs with cat would create the wrong input. Start by asking the archive tools what you actually downloaded:

zipinfo -1 backup-part001.zip | head
unzip -l backup-part001.zip

For this backup, the listing showed nested ZIP files and a manifest. If your result instead describes a genuinely split archive format, follow the backup product’s documented recovery process rather than applying the commands below blindly.

Create a private working area

Keep migration material away from the website checkout. The following example creates an owner-only workspace under your home directory:

umask 077

WORK="$HOME/wordpress-migration"
SOURCE="$WORK/source"
PRIVATE="$WORK/private"
REPORTS="$WORK/reports"

mkdir -p "$SOURCE" "$PRIVATE/outer" "$PRIVATE/extracted" "$REPORTS"
chmod -R go-rwx "$WORK"

Copy the downloaded backup files into $SOURCE. Do not put the workspace inside a Git repository, a public cloud-synchronised folder or a shared directory.

The umask 077 setting makes new files private to your user, while chmod -R go-rwx removes all group and other permissions from the existing tree without marking ordinary files executable. Use this alongside disk encryption and a properly secured computer.

Record the source before extracting it

List the files and generate a checksum record while the downloads are still untouched:

find "$SOURCE" -maxdepth 1 -type f -name '*.zip' -print | sort

find "$SOURCE" -maxdepth 1 -type f -name '*.zip' -exec \
  shasum -a 256 {} \; | sort > "$REPORTS/source-sha256.txt"

cat "$REPORTS/source-sha256.txt"

For vEducate, the evidence at this point was six checksum lines. I also recorded the byte size reported for each file and the combined total. Your counts will be different, but the report should be specific enough to identify the exact snapshot later.

To prove that retained files have not changed, run the check from the same source location using a checksum file whose paths match that layout:

cd "$SOURCE"
shasum -a 256 -c "$REPORTS/source-sha256.txt"

Every entry must report OK. A mismatch does not automatically mean malicious tampering; an interrupted download is much more likely. It does mean you do not have the snapshot you recorded, so stop and download or investigate the affected file.

Test every outer archive

A successful browser download does not prove that the ZIP can be read from beginning to end. Test all outer archives before extraction:

for archive in "$SOURCE"/*.zip
do
  echo "Testing $(basename "$archive")"
  unzip -tq "$archive" || exit 1
done

The expected result is a successful test for every file. || exit 1 stops at the first damaged archive rather than letting later output hide the failure.

For vEducate, all six outer archives passed.

Audit paths before extraction

ZIP files can contain absolute paths or .. segments that write outside the intended directory. Backups you created yourself are less suspicious than an archive downloaded from an unknown source, but they still deserve the same check.

Run this against each outer archive:

for archive in "$SOURCE"/*.zip
do
  echo "Checking paths in $(basename "$archive")"

  zipinfo -1 "$archive" | awk '
    /^\// || /(^|\/)\.\.(\/|$)/ || /\\/ {
      print "unsafe: " $0
      bad=1
    }
    END { exit bad }
  ' || exit 1
done

No unsafe: lines are expected. This checks names for Unix absolute paths, parent-directory traversal and backslash paths. It does not inspect the ZIP entry type. Reject symlinks, devices and other special entries separately before extraction:

for archive in "$SOURCE"/*.zip
do
  python3 - "$archive" <<'PY' || exit 1
import stat
import sys
import zipfile

with zipfile.ZipFile(sys.argv[1]) as source:
    for entry in source.infolist():
        file_type = stat.S_IFMT(entry.external_attr >> 16)
        if file_type not in (0, stat.S_IFREG, stat.S_IFDIR):
            raise SystemExit(f"unsafe entry type: {entry.filename}")
PY
done

Together, these checks establish that the listed names and entry types are suitable for extraction into the chosen owner-only root. They do not establish that the contents are safe to publish. Run both checks against the nested archives as well.

Extract the outer containers and test the children

Give each outer ZIP its own directory. This avoids collisions between manifests and makes it clear which downloaded part supplied each child archive:

for archive in "$SOURCE"/*.zip
do
  name=$(basename "$archive" .zip)
  target="$PRIVATE/outer/$name"

  mkdir -p "$target"
  unzip -q "$archive" -d "$target"
done

Now inventory and test the nested ZIPs:

find "$PRIVATE/outer" -type f -name '*.zip' -print | sort

find "$PRIVATE/outer" -type f -name '*.zip' -print | sort |
while IFS= read -r archive
do
  echo "Testing $(basename "$archive")"
  unzip -tq "$archive" || exit 1
done || exit 1

The vEducate source contained ten child archives, and all ten passed. I then ran the same path audit against those children before extracting the database or uploads.

Do not extract everything just because it is present. The importer needed neither WordPress core nor the complete plugin tree. Leaving them archived reduced the amount of private material exposed.

Extract only what the migration needs

For an initial content inventory, extract the database and overlay the upload parts into one wp-content/uploads tree:

mkdir -p "$PRIVATE/extracted/database"
mkdir -p "$PRIVATE/extracted/wp-content"

DB_COUNT=$(find "$PRIVATE/outer" -type f -name '*_backup_db.zip' | wc -l | tr -d ' ')
test "$DB_COUNT" -eq 1 || {
  echo "Expected 1 database archive; found $DB_COUNT"
  exit 1
}
DB_ARCHIVE=$(find "$PRIVATE/outer" -type f -name '*_backup_db.zip' -print -quit)
unzip -q "$DB_ARCHIVE" -d "$PRIVATE/extracted/database"

UPLOAD_LIST="$REPORTS/upload-archives.txt"
find "$PRIVATE/outer" -type f -name '*_backup_uploads.part*.zip' -print | sort > "$UPLOAD_LIST"
UPLOAD_COUNT=$(wc -l < "$UPLOAD_LIST" | tr -d ' ')
test "$UPLOAD_COUNT" -eq 5 || {
  echo "Expected 5 upload archives; found $UPLOAD_COUNT"
  exit 1
}

while IFS= read -r archive
do
  unzip -oq "$archive" -d "$PRIVATE/extracted/wp-content"
done < "$UPLOAD_LIST"

The count checks stop the script before unzip if the database or any expected upload part is missing. The expected upload count of five comes from this backup’s recorded inventory; use your own known count. If your backup uses different names, locate the payload from the recorded archive listing rather than changing a wildcard until it happens to match something.

WPvivid upload parts are intended to overlay into the same tree. The -o flag makes that reviewed overwrite policy non-interactive; without it, unzip can prompt on a shared manifest and consume input intended for the archive-list loop. The untouched outer directories and their manifests remain the evidence for each source part.

Build an inventory you can compare later

At minimum, record SQL files, upload counts, total bytes and the largest files:

find "$PRIVATE/extracted/database" -type f -name '*.sql' -print -exec wc -lc {} \;

find "$PRIVATE/extracted/wp-content/uploads" -type f | wc -l

find "$PRIVATE/extracted/wp-content/uploads" -type f -print0 |
  xargs -0 stat -f '%z' |
  awk '{ total += $1 } END { print total " bytes" }'

find "$PRIVATE/extracted/wp-content/uploads" -type f -print0 |
  xargs -0 stat -f '%z %N' |
  sort -nr | head -20

The stat syntax above is for macOS. On Linux, use stat -c '%s %n' instead.

My accepted extraction contained two SQL files totalling 242,585,629 bytes and 1,376,665 lines. The complete upload source held 14,988 files totalling 906,454,264 bytes. The largest real upload was below Cloudflare Pages’ individual-file limit; large cache logs were not public media and did not need to move.

These figures did not prove the content migration was correct. They gave me a stable source inventory to compare with importer results. If a rerun suddenly found one SQL file or half the uploads, it could fail before producing a plausible-looking but incomplete website.

I also inventoried the database tables before selecting anything. The migration needed posts, a small allowlist of post metadata, terms, taxonomy relationships, comments and the specific plugin table that held redirect definitions. It did not need user tables, security logs or more than a million historical 404 request-log rows.

The safe pattern is to allow known tables and fields, not import the whole database and try to remove private material afterwards.

Keep the raw source out of Git

None of the following belongs in the website repository:

  • source or child ZIP archives;
  • SQL dumps or a restored database;
  • wp-config.php or copied .htaccess files;
  • user and user-meta tables;
  • password hashes, API credentials or private keys;
  • raw comments containing email addresses, IP addresses or user agents;
  • security, analytics and request logs; or
  • plugin caches and old backup directories found under uploads.

Only sanitised public content, selected public media and aggregate migration reports should cross that boundary. Add archive and SQL patterns to .gitignore, but do not rely on .gitignore alone. A repository validation step should reject backup formats, database signatures and common credential patterns before a commit can be accepted.

The audit gate

I considered the backup ready for importer development only when all of the following were true:

  1. All six downloaded files were present with recorded sizes and SHA-256 hashes.
  2. Every outer and child ZIP passed a complete integrity test.
  3. Archive paths contained no absolute or parent-directory traversal entries.
  4. The database and all five upload payloads extracted into an owner-only workspace.
  5. SQL and upload totals were recorded as fixed source evidence.
  6. The useful tables and media were distinguishable from private and operational data.
  7. The original archives remained untouched so a clean extraction could be repeated.
  8. No raw migration source had entered the Git working tree or history.

That gate is intentionally boring. Discovering a missing upload part while designing the importer is inconvenient; discovering it after changing DNS is much worse.

The REST API still had value after this point. I could compare public counts, dates and routes with the live site. WXR remained a recovery option if the database archive later proved unusable. Neither needed to compete with a full snapshot that had already passed the audit.

With a trusted source and a recorded inventory, the next job was controlled transformation rather than recovery guesswork. Part 3 covers the importer, the WordPress-to-Astro data mapping and how the new site preserved historical URLs without carrying the WordPress application into production.