This is part 6 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 6: The pitfalls list: a reference of everything that broke
Some of the sharpest lessons above are worth having in one scannable list, the kind of thing you’d want open in a second tab while you’re doing your own migration.
rsync src1/ dst1/ src2/ dst2/is not two pairs. rsync treats everything but the last argument as sources, the last as a single destination. Run one rsync invocation per source/destination pair, always.rsync -afails on a non-POSIX SSHFS mount. The Storage Box rejects arbitrary chown/chmod, throwing mass cosmetic “Permission denied” errors under-aeven though content transfers completely. Use-rlt --no-perms --no-owner --no-groupinstead.- A control panel’s built-in zip/compress export is a dead end for large sites. It duplicates data inside the same account’s quota while it runs, can take well over an hour, and then fails with a quota error and leaves nothing usable. Go straight to SFTP for anything sizable.
- A subdirectory WordPress install’s export zip has to live inside that subdirectory, not the docroot root, or WordPress’s own rewrite rules will 404 it before you can extract it.
- Don’t keep
.bakcopies “just in case” after a verified media offload. They silently accumulate; one project lost ~20GB of disk this way across seven sites before it was caught. - Scan the whole site tree for large media, not just the conventional uploads directory. Non-standard media directories at the docroot root are common on older or hand-built sites.
- A symlinked media offload needs
open_basedirupdated on the PHP-FPM pool, or PHP-driven features through that path (including the WordPress media uploader) break with a misleading “not writable” error, even though raw filesystem tests through the same path always succeed. - A remote database host with the MySQL port firewalled means no direct
mysqldump. Fall back to phpMyAdmin’s browser export, or a small PHPmysqliscript if shell access itself is disabled. - A control panel may double-prefix new database names and users with the account name. Not a bug, just a naming surprise to expect.
- wp-cli can fatal-error while a host’s caching/optimization plugin is still active, because it crashes on bootstrap outside that host’s specific environment. Bypass it with a direct SQL edit to
wp_options.active_pluginsover SSH instead of relying onwp plugin deactivate. - Always grep the real
table_prefixfromwp-config.phpbefore assumingwp_. - A stray static
index.html/Default.htmlin the docroot silently shadows WordPress, because Apache’s own rewrite condition (!-f, “only rewrite if no real file matches”) sees the static file and never routes toindex.php. HTTP 200 tells you nothing; check the actual page title and content, especially right after a domain first goes live. - Issuing a Let’s Encrypt cert without explicitly passing the www alias drops www from the certificate’s SAN entirely. Verify with
openssl x509 -noout -ext subjectAltName, not an HTTP status check on the root domain. - Issuing or renewing a cert doesn’t guarantee the running services picked it up. nginx needs an explicit reload; an nginx-fronting-Apache SNI proxy setup can need a full Apache restart, not just a reload, before the backend actually serves the new cert/vhost.
- Config flags claiming SSL is enabled are not proof a certificate exists. We found this gap causing a silent mail outage (clients refusing a hostname-mismatched fallback cert) on more than one domain, all traced back to a batch script that wrote the flag without completing real issuance.
- A missing DKIM DNS record is a different failure than a missing DKIM key. Check that the key was actually published, separately from confirming it was generated.
- A
mail.<domain>CNAME pointing at the bare root domain rides along silently when you move the root A record, breaking mail client connectivity even while MX-routed mail elsewhere keeps working. - Cloudflare’s Proxied mode breaks every mail protocol through any hostname it’s applied to. Every mail-related DNS record needs to be DNS-only.
curl -H "Host: x"against a bare IP only sets the HTTP header, not the TLS SNI, and will always show you the wrong certificate. Usecurl --resolveto test real per-domain SSL.- Loopback (127.0.0.1) testing on a multi-vhost box always falls through to the default certificate/vhost. Test against the real public IP.
- “Email is down” is very often a stale cached password on the client, not a server problem. Confirm with a server-side auth test against the new credential before you go looking anywhere else.
- A webmail client showing “old mail is missing” may just mean the relevant IMAP folders were never subscribed, not that the mail is gone. Check the actual mailbox with a raw search tool before assuming data loss.
- A Cloudflare zone’s assigned nameserver pair is per-zone, not fleet-wide. Never assume the pair you fixed on one domain is the pair another domain needs; check each zone’s own instructions.
- Auto-scan on a new DNS zone import can silently miss non-standard TXT records, DKIM subdomains especially. Diff the imported zone against the old one manually.
- Redirect Rules require the DNS record to be Proxied, the one deliberate exception to an otherwise DNS-only fleet standard; forgetting this makes the redirect simply not fire.
- A custom fail2ban jail using Hestia’s own
hestiaaction needs to reuse one of its known chain names, not invent a new one. Hestia’sv-add-firewall-chainscript only auto-configures a fixed list of services (SSH, FTP, MAIL, DNS, WEB, DB, HESTIA, RECIDIVE); anything else requires an explicit port argument that the action definition never passes, so the jail fails at startup with an opaque “Script error.” Reuse the closest matching chain instead of inventing a new name; we pointed a new XML-RPC flood jail at the existingWEBchain, since the abuse traffic was HTTP on the same ports anyway. - fail2ban logging a “Ban” does not mean the IP was actually blocked. Once a jail’s firewall action fails once at startup, fail2ban keeps that broken action cached in memory; fixing the config and running a plain
fail2ban-client reload <jail>does not re-bind it, so every later “ban” after that point is just internal bookkeeping with no real rule behind it. Onlyfail2ban-client reload --restart <jail>, or a full service restart, actually re-initializes the action. Confirm a real block exists directly iniptableswith a synthetic test IP; don’t trust jail status output alone. - A control-panel account split can leave two credential lines for the same mailbox, one still pointing at the old shared account’s home directory. The mail service’s lookup stops at the first match, so the leftover line can silently win over the correct one. Audit every migrated mail domain’s credentials file for duplicate entries per mailbox, don’t assume one clean line means one line exists.
- A mail-config directory can keep the wrong Unix group after an account split, which blocks the mail service from opening the credentials file at all and fails every login on that domain, not just one mailbox. This shows up as a generic auth error everywhere except the mail service’s own log, where it’s a plain permission-denied line.
- A host’s bundled optimization/security plugin can fatal-error in some execution contexts and not others, even after migration. We had one fatal reliably under wp-cli while the same site’s front end rendered normally on every request – a working page load is not proof a leftover host plugin is harmless.
- A stale
@include_once()hook inwp-config.phppointing at a path that only existed on the old host fails completely silently, because the leading@suppresses the resulting warning outright. It can sit there indefinitely across dozens of sites with zero visible symptom; grep every migratedwp-config.phpfor old-host-specific includes as a matter of course, don’t wait for a symptom to go looking. - An SPF record’s
include:can point at a subdomain the old host auto-generated for itself, a dead reference once that host stops serving it. It rarely breaks mail delivery outright under a permissive qualifier, which is exactly why it survives unnoticed; audit SPF records fleet-wide as their own pass, separate from confirming mail delivery works. - A DKIM TXT record can exist and still be completely inert, if it’s published at the bare domain instead of the required
<selector>._domainkey.<domain>name. This is worse than a missing key, because a superficial “is there a DKIM record” check says yes while real verification never looks at that name at all. - A WordPress plugin sending mail via OAuth to an external mailbox provider fails in a way no server-level mail check will ever catch, because the message never reaches the server’s own mail transport in the first place. The submission still logs successfully inside WordPress; the only place the failure appears is that plugin’s own send log, checked individually per site.