Migrating a WordPress Fleet, Part 3: The Migration Playbook, Files, Databases, and WordPress

This is part 3 of an 8-part series on migrating a fleet of roughly 40 WordPress sites from two shared-hosting providers (A2 Hosting and SiteGround) onto a self-managed Hestia Control Panel VPS with Cloudflare as the DNS layer. “Self-managed Hestia Control Panel VPS” does not imply exotic or special-purpose hardware: Hestia runs on any generic x86_64 Linux box. Our own instance is a Hetzner Cloud CPX32 (4 vCPU, 8GB RAM, roughly 160GB SSD disk, running Ubuntu 24.04), reachable over Hetzner’s standard shared datacenter network like any other cloud VPS, not a dedicated line or special connection.

This series is written for the sysadmin who is considering doing this themselves and wants to know exactly how hard it actually is before they start.

Part 3: The migration playbook: files, databases, and WordPress

Moving the files

We used two techniques depending on the source and site size.

For most of the fleet (the A2 side), a custom SFTP-to-SFTP Python script built on paramiko: it opens SFTP connections to both the source and destination hosts and streams each file through memory in fixed-size chunks (using prefetch()/set_pipelined(True) for throughput), never staging a full copy on local disk. The approach that made this reliable at scale:

  • Build the complete file manifest once, with a single-threaded recursive walk, and cache it to disk before starting any transfer.
  • Maintain a plain-text, append-only “completed paths” file, flushed after every single file, so the whole operation is resumable if interrupted.
  • Use a thread pool (roughly 8-10 workers), each with its own pair of source and destination SFTP connections. paramiko’s SFTPClient is not thread-safe to share across workers.

Typical throughput for many-small-files workloads was 7-9 files/second; large single files were bandwidth-bound instead. This got generalized into a reusable script (pull_site_generic.py <src_user> <src_base> <dst_base> <tag>) so any future domain migration could reuse the same logic, with state files namespaced by a tag so multiple domains could transfer concurrently.

For simpler cases (the SiteGround side, and smaller A2 sites), a straightforward wget mirror ran directly on the destination server against the source’s FTP endpoint, backgrounded so it survives a dropped SSH session:

nohup wget -m --user=<ftpuser> --password='<pass>' ftp://ftp.<domain>/ > wget.log 2>&1 < /dev/null &
disown

This is simpler but has its own gotcha: it produces a double-nested docroot (ftp.<domain>/ftp.<domain>/<domain>/public_html/...), which needs flattening afterward. Move the real public_html contents up into Hestia’s actual docroot, and move the leftover wrapper directories and any stray placeholder files to a review folder rather than deleting them outright. Never delete real content directly during a migration. If something looks like leftover cruft, move it aside with a clearly-named suffix (we used migration-source-review/) so a human can confirm before it’s gone for good. This saved us at least once, when a “stray leftover” file turned out to be actively shadowing a live WordPress install (see the pitfalls list).

If you’re running these transfers from any kind of sandboxed automation environment rather than a real always-on machine, know the constraints going in: no outbound SSH from a locked-down sandbox means you route everything through a machine that does have it; ephemeral home directories mean regenerating SSH keys each session; and hard wall-clock caps on remote command execution (we hit roughly 110-180 seconds per call) mean nohup ... & disown only survives if the backgrounded process is running on the actual remote server, not on an intermediate jump host whose own session will be torn down when the calling tool returns. Building a large SFTP manifest (tens of thousands of files) from an intermediary machine was unreliable for exactly this reason; moving the manifest-build-and-transfer logic to run entirely on the destination server via nohup/disown fixed it.

Offloading large media to cheaper storage

For sites with large media libraries, we moved directories to the Storage Box and replaced them with a symlink (mv wp-content/uploads wp-content/uploads.bak && ln -s /mnt/media-<account>/uploads wp-content/uploads), verified byte-for-byte via rsync (checking both size and file count on both sides), and only then deleted the local .bak copy.

Two things went wrong here:

First, we didn’t delete the .bak directories promptly enough. “Just in case” backups sitting on local disk after a verified-good offload silently wasted about 20GB of VPS disk across seven sites, at one point pushing disk usage to 82% before it was caught and cleaned up, dropping back to 43%. If you’ve verified the copy, trust it and delete the backup. Keeping it “just in case” on the exact disk you’re trying to relieve pressure on defeats the purpose.

Second, and more subtly damaging: offloading only wp-content/uploads isn’t enough. Several sites had large media sitting in non-standard, root-level directories (one site had a media/music/press/video tree at the docroot root, roughly 6GB; another had a similarly-sized custom directory unrelated to the standard WordPress uploads path). These were missed on sites that had already been marked “done.” Run du -sh across the entire site tree, not just the conventional WordPress media path, before declaring an offload complete.

Third, this broke a live feature for real users: after symlinking media to the Storage Box mount, PHP-FPM refused to read or write through that path unless the pool’s open_basedir setting explicitly included the new mount point. nginx serves static files through the symlink just fine regardless, which masks the problem completely for anything that’s just a browser loading an image. But anything PHP-driven that touches that path (thumbnail generation, and critically, the WordPress media uploader itself) breaks with an error that looks like a permissions problem (“Unable to create directory… is its parent directory writable by the server?”) but isn’t. Checking with sudo -u <user> touch on the path always succeeds and tells you nothing, because raw filesystem access ignores open_basedir entirely; the only real test is triggering the actual PHP code path through a real HTTP request. Once we knew to check for it, we found this gap on 8 of 9 sites using the offload pattern. The fix is a one-line edit to the PHP-FPM pool config (/etc/php/8.x/fpm/pool.d/<domain>.conf, note the pool filename typically includes the TLD) appending the new mount path to open_basedir, followed by systemctl reload php8.x-fpm.

Moving the database

A2’s remote MySQL was firewalled from outside connections, so a direct mysqldump over the network wasn’t an option. The standard path was phpMyAdmin’s browser Export (the plain download, not “view as text”), transferred to the destination and imported with:

mysql -u <user> -p'<password>' <database> < dump.sql

For the rare account where shell access itself was disabled by the host, we fell back to a small ad hoc PHP script using mysqli to run the export instead. This is useful for a host that locks down shell access entirely.

On the Hestia side, database creation is a single command:

v-add-database <hestia-user> <dbname> <dbuser> <password> mysql

One surprise worth knowing in advance: Hestia prefixes both the database name and the database user with the account name, so a database you think of as jacquinaylor/jacquin becomes yvodcom_jacquinaylor/yvodcom_jacquin on disk (and if the account name itself repeats a word already in your intended name, you can end up with an odd-looking double prefix). This is expected behavior, not a bug, but it will surprise you the first time you go looking for a database by the name you gave it and can’t find it.

WordPress-specific cleanup, every single time

Every WordPress install migrated off a managed host carries baggage from that host’s own optimization/management plugins. Before considering a site “done,” check for and deactivate (in our case, this fleet standardized on always checking for all of these regardless of which host a given site came from, since some sites had clearly been migrated hosts before and still carried the previous host’s plugins):

  • sg-cachepress, sg-security, sg-ai-studio, siteground-migrator, wordpress-starter (SiteGround’s stack)
  • a2-optimized-wp (A2’s own optimization plugin, found active even on sites that otherwise looked SiteGround-only, evidence of an earlier unclean A2-to-SiteGround migration nobody had fully cleaned up)

The complication: wp-cli itself can fatal-error while sg-cachepress is still active, because the plugin crashes on bootstrap outside of SiteGround’s specific environment. You can’t just wp plugin deactivate your way out of this. The workaround that worked reliably: a small inline PHP script run over SSH (via a heredoc), using mysqli directly against the wp_options table to read the serialized active_plugins value, unserialize it, filter out the host-specific plugins, re-serialize it, and write it back:

UPDATE <database>.<prefix>options SET option_value='a:0:{}' WHERE option_name='active_plugins';

(That example clears every active plugin; in practice you’d unserialize, filter, and re-serialize to preserve the legitimate ones, but the direct-SQL bypass is the important part: it sidesteps wp-cli entirely for the one operation that keeps crashing it.)

Also grep the site’s actual table_prefix in wp-config.php before assuming wp_. Plenty of these sites had non-default prefixes, sometimes clearly hand-set at some point in the site’s history for security-through-obscurity reasons that no longer matter but that will absolutely break your migration script if you hardcode wp_.

Clean .htaccess down to WordPress core’s bare # BEGIN WordPress / # END WordPress block, watching for host-specific cruft beyond just SiteGround’s own additions (we found LiteSpeed cache blocks and PHP-version AddHandler lines left over from yet another prior host on one site). Run a backdoor scan, but know the common false positives in advance so you don’t waste time chasing them: wp-includes/class-json.php and wp-admin/includes/class-pclzip.php are legitimate WordPress core files that pattern-match as suspicious to naive scanners; a root-level php_errorlog file is a normal error log, not an implant.

The lesson that cost us the most repeated debugging time: HTTP 200 proves nothing

This happened on three separate domains, and each time it initially looked like a different bug.

A domain would come up clean after migration: DNS correct, SSL issued, curl returning a crisp HTTP/2 200. And yet a real visitor would see a generic “Coming Soon” placeholder instead of the actual site. The root cause, every time: a stray static index.html (and sometimes a Default.html) left sitting in the docroot from an earlier host’s migration tooling or a “coming soon” placeholder that was never cleaned up. WordPress’s own .htaccess rewrite rule includes a condition like:

RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule . /index.php [L]

That !-f means “only rewrite to index.php if the requested path doesn’t already match a real file.” A static index.html sitting right next to index.php matches a real file, so Apache serves it directly and never even considers routing to WordPress. The HTTP status code is a perfectly healthy 200, from a real file, on a real web server, none of which tells you anything about whether it’s the file you actually wanted.

The fix each time was the same: move the stray static file(s) aside (never delete, per the standing policy above), and verify the fix by checking the page’s actual title and content, not the status code. Always check what’s actually being served, especially right after a domain’s DNS and SSL first go live, because that’s exactly the window when a leftover placeholder file is most likely to still be sitting there.


Leave a Comment