Migrating a WordPress Fleet, Part 5: Email Is the Hard Part

This is part 5 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 5: Email is the hard part

If there’s one section of this migration to budget the most caution for, it’s mail. Web hosting failures are visible immediately and to everyone. Mail failures are silent, per-mailbox, and often only surface days later when someone mentions they “haven’t gotten an email in a while.”

How we moved mail content

We deliberately did not use IMAP or imapsync, because that would require knowing or entering every individual mailbox’s real password, which was off the table. Instead, mail content moved the same way the rest of the files did: a direct SFTP copy of the raw Maildir structure (/home/<user>/mail/<domain>/<account>/{cur,new,tmp}), since cPanel’s Maildir layout is filesystem-accessible over SFTP without needing any mail-specific credentials at all. This means you never need the end user’s password, but it also means you’re moving raw maildir files rather than trusting a purpose-built sync protocol, so verify message counts on both sides after the copy.

New mailbox passwords on the destination were generated randomly server-side and never retained anywhere in logs or chat history; where technically possible (an account move rather than a fresh migration), we preserved the existing password hash directly and verified it worked with doveadm auth test, avoiding any password churn for the end user.

DNS records: what to touch, what to leave alone

Before assuming a domain’s mail needs migrating at all, check where it’s actually routing. If mail already goes through Google Workspace, iCloud, or some other external provider, leave MX, SPF, DKIM, and DMARC alone entirely. Only the web-facing A/CNAME records need to move.

If mail is genuinely moving to the new server, two DNS mistakes caused real, visitor-facing (well, sender-facing) breakage on this project:

A mail.<domain> CNAME pointing at the bare root domain is a common cPanel default. If you miss this and move the root A record to the new server without also handling the mail hostname, the mail hostname silently follows the root record, breaking any client configured with that hostname as its IMAP/SMTP server, even while MX-routed mail elsewhere keeps working. Convert it to its own static A record pointing at the old host, if mail isn’t part of this cutover, before you touch the root record.

Cloudflare’s Proxied mode breaks mail entirely for any hostname it’s applied to. The proxy only understands HTTP(S); IMAP, POP, and SMTP connections through a proxied A or CNAME record just fail. We hit a real outage where mail., imap., pop., and smtp. records had all been left Proxied after a routine DNS cutover. Every mail-related hostname needs to be DNS-only, full stop, with no exceptions.

Outbound mail and the port 25 problem

Most cloud VPS providers, Hetzner included, block outbound port 25 by default on new accounts, specifically to fight spam from freshly-provisioned servers. Getting it unblocked typically requires the account to age for a while and generate a support history (in our case, roughly a month plus a first invoice) before the provider will even consider a request. Until then, outbound mail needs a smarthost.

We used a free-tier SMTP relay (Brevo, formerly Sendinblue) configured as a single, system-wide relay for all outbound mail from the box, via v-add-sys-smtp-relay. To be precise about what this does and doesn’t require: it’s one relay for the whole server, not something you configure per domain. What does matter per domain for deliverability is that domain’s own DKIM signing, using Hestia’s auto-generated key, published in its own DNS. The relay handles the SMTP hop; DKIM authentication is still per-domain regardless of which relay carries the message.

DKIM: generated is not the same as published

Hestia will happily generate a DKIM keypair for a mail domain without you ever publishing the public half as a DNS TXT record. We found this gap on a domain that had otherwise gone live weeks earlier: the private key existed on disk, the DNS zone had no corresponding mail._domainkey TXT record at all. If you’re moving an existing DKIM setup rather than generating fresh (for instance, during an account-to-account move on the same server), reuse the existing key material and confirm the DNS TXT value is byte-identical to what’s already published, rather than regenerating and creating a mismatch.

The symptom that wasn’t a symptom, three times

“Email is down” reports, more than once, turned out to be a stale cached password on the client (an iPhone Mail app, a Thunderbird profile) after a server-side password reset, not any actual server or DNS problem. The tell is straightforward: doveadm auth test succeeds server-side with the new password while the client still fails. This pattern recurred at least three times on a single domain across different mailboxes, and every time it looked, from the user’s description, exactly like a real outage. Have this check ready before you go digging into DNS or Dovecot logs on a fresh “email is down” report.

Related, and easy to overlook: don’t default a mailbox reset to a previously-used convention password without explicit confirmation from whoever needs to actually use it. We made this mistake once; it was caught immediately, but it’s a good habit to build a hard stop around.

Old mail present but invisible

One report of “old emails missing from webmail” turned out, after checking with doveadm search, to involve mail that was never deleted at all: over 19,000 of roughly 23,000 total messages, more than a year old, sitting on disk exactly where they should be. The actual cause was that Dovecot’s subscription list only included a fraction of the account’s real folders, and Roundcube’s default view only shows subscribed folders. Over a hundred folders, mostly client- or property-named, were simply invisible in the webmail sidebar despite holding real mail. The fix is either manual (subscribe via the webmail client’s folder settings) or scripted (doveadm mailbox subscribe -u <user> '<foldername>' per folder).

The account-split left old passwords first in line

Splitting domains out of one shared hosting account into their own dedicated accounts (see Part 3) has a mail-specific failure mode we didn’t catch the first time around: the per-domain mail credentials file can end up with two lines for the same mailbox, one still pointing at the old shared account’s home directory, one at the new dedicated account. Dovecot’s passwd-file lookup matches sequentially and stops at the first hit, so whichever line was written first silently wins, regardless of which one you think is authoritative. In our case the leftover line was the old one, meaning a mailbox could keep validating against a stale, pre-split credential indefinitely, or have its actual mail folder resolved against a home directory that no longer exists, with no error surfaced anywhere obvious. The fix is a straightforward audit: for every migrated mail domain, check its passwd-style credentials file for more than one line per local part, and remove whichever one doesn’t point at the account that now actually owns the domain. We found this on five separate domains from a single batch migration, so treat it as a checklist item, not a one-off.

A wrong group owner is a total, not partial, mail outage

A related but distinct bug: the directory holding that same credentials file can survive the account split still owned by the wrong Unix group. Everything about the mailbox looks correct – the account exists, the Maildir is in the right place, the password was preserved – but the mail service literally cannot open the credentials file to check anything, and fails every login attempt for every mailbox on that domain. This is a harder outage to catch than a stale password, because a generic auth-lookup-failed error looks identical to a dozen other misconfigurations; the actual cause only shows up as a plain permission-denied line in the mail service’s own log, not in anything a control panel surfaces. We found three domains with this exact gap, live and unfixed since the day of the original account split. If a migrated mail domain’s logins are failing across the board rather than for one user, check the group ownership of its mail-config directory before anything else.

DKIM published in the wrong place is worse than not published at all

Part of this series already covers a DKIM key that was generated but never published. Here’s the sibling bug: a DKIM-shaped TXT record that is published, but at the bare domain instead of the required <selector>._domainkey.<domain> subdomain. A quick DNS glance says “yes, there’s a DKIM record here,” which is exactly what makes this worse than the missing-key case: it passes a superficial check while being completely invisible to any real mail receiver, since DKIM verification only ever queries the specific selector subdomain. In our case it was also a leftover from before the migration, an entirely different key than the one Hestia had actually generated for the domain post-move, so it wasn’t even stale data, just dead weight sitting at the wrong DNS name. Worth an explicit check across a fleet: does a TXT record exist at the actual mail._domainkey (or whatever selector your panel uses) name, not just somewhere on the domain.

An SPF include can point at the old host, forever

Several domains on this fleet carried an SPF include: mechanism pointing at a subdomain the old host auto-generated and served on its own infrastructure (a pattern like <domain>.spf.auto.<oldhostservice>.net). SPF records don’t get audited nearly as often as MX or A records during a migration, because mail can keep flowing acceptably with a slightly wrong SPF record, especially under a permissive ~all qualifier. But it’s still a dead reference sitting in a security-relevant DNS record, and depending on the old host’s own DNS lifecycle, that included lookup can eventually resolve to nothing, or worse, to whatever a future tenant of that old hosting namespace ends up controlling. We found this stale include on the large majority of a batch-migrated fleet’s domains, all traceable to the same old-host default that nobody had reason to look at again once mail delivery itself was working. Treat an explicit SPF-record audit as its own fleet-wide pass, separate from confirming mail delivery works.

Plugin-level OAuth mail fails a completely different way

Everything above is server- and DNS-level mail. There’s a separate failure class that lives entirely inside WordPress: a mail-sending plugin authenticating to an external mailbox provider (Gmail/Google Workspace, in our case) via OAuth rather than through the server’s own mail transport. When that OAuth connection expires or gets revoked, none of the server-level signals apply at all – the relay is fine, DNS is fine, the local mail queue is empty, because the message never reaches server-level mail in the first place. The form or notification still “sends” from WordPress’s point of view (the submission gets logged, the code path runs without throwing), so the only place the failure actually shows up is that specific plugin’s own send log, checked individually, for every domain using that pattern. If a migration inherits a site using a plugin-level OAuth mail connection instead of the server’s own mail transport, budget time to check that connection’s health explicitly; it fails silently and it fails independently of every other mail check in this list.

What we didn’t migrate

Mailman mailing lists have no equivalent in Hestia. One domain had three real lists (one with real archive volume, two smaller announcement lists). We made the deliberate decision to drop them entirely rather than try to replicate mailing-list infrastructure, including the forwarders that used to route into them. If mailing lists matter to your fleet, plan for that separately; it’s not something a control panel migration carries along for free.


Leave a Comment