Migrating a WordPress Fleet, Part 4: SSL/TLS, Running Let’s Encrypt Across a Multi-Domain Box

This is part 4 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 4: SSL/TLS: running Let’s Encrypt across a multi-domain box

Hestia wraps Let’s Encrypt issuance behind a single command:

v-add-letsencrypt-domain <user> <domain> 'www.<domain>'

Run this only once DNS is confirmed live via a real DNS-over-HTTPS query, not a possibly-cache-stale local dig. Then expect to hit, in roughly this order, every gotcha below.

Issuing the cert doesn’t reload nginx. v-add-letsencrypt-domain writes the correct new config to disk, but the running nginx process can keep serving the previous (Hestia’s default self-signed) cert until you explicitly systemctl reload nginx. nginx -T will show you the correct config on disk while live traffic disagrees; that mismatch is the tell.

Hestia’s stack fronts SSL with nginx, which proxies to an Apache backend over SNI, and the backend can lag independently. Even after an nginx reload, we hit at least one case where the Apache backend kept serving its own default vhost, Hestia’s generic “Success! Your new web server is ready to use” placeholder, over HTTPS, for a domain that nginx itself reported as a clean 200. A plain systemctl reload apache2 wasn’t enough in that case; only a full systemctl restart apache2 picked up the new vhost. After any SSL change, restart (not just reload) both nginx and Apache, and check actual page content, not just the status code.

Omitting the www alias silently drops it from the certificate. If you don’t explicitly pass the www hostname as an alias argument, the resulting cert’s SAN covers only the bare domain, and https://www.<domain>/ fails with a certificate hostname mismatch. Verify what actually got issued:

openssl x509 -noout -ext subjectAltName -in /path/to/cert.crt

Don’t just check that the root domain returns 200 and assume www is covered.

Mail-only domains (no web vhost) get SSL through an undocumented fourth argument:

v-add-letsencrypt-domain <user> <domain> '' yes

This auto-derives the certificate’s CN as mail.<domain>, adds webmail.<domain> as a SAN if webmail is enabled, and validates through the existing webmail (Roundcube) nginx vhost rather than needing a placeholder web domain. It does require an actual webmail.<domain> A record to exist for validation to succeed, which is easy to forget if the domain has never had a web presence on the new server at all.

Configuration metadata is not proof anything actually happened. More than once, a domain’s mail.conf showed SSL='yes' and LETSENCRYPT='yes', written by an earlier batch script, with no cert file, no vhost, and no Dovecot SNI configuration actually present. The visible symptom, if you’re the end user, is confusing: mail delivers to the server fine, but any mail client enforcing strict TLS refuses to connect because it gets served a certificate for the wrong hostname (Hestia’s default panel.<yourdomain> cert), and it just looks like “email is down.” Don’t trust the flags. Check that the actual Dovecot local_name SNI block and the cert files on disk both exist.

Two real outages are worth recounting in full, because they’re the sharpest version of “verify, don’t assume” this project produced.

Outage one, server-wide Apache, about one minute. A batch script had blindly written SSL='yes' for a domain with no cert ever actually issued. A later rebuild/reload cycle that assumed the flag was trustworthy took down Apache across the entire server, affecting every domain, not just the one with the bad flag. The fix going forward: gate any script that reloads services on an actual HAVECERT-style check, and always run a config test (apache2ctl configtest / nginx -t) before any reload, never after.

Outage two, server-wide Dovecot, about five minutes. While reusing an existing certificate for a mail-only domain rather than reissuing, we populated the location Dovecot and nginx actually read from (/home/<user>/conf/mail/<domain>/ssl/) but not Hestia’s own canonical tracking location (/usr/local/hestia/data/users/<user>/ssl/mail.<domain>.*). Later, v-rebuild-mail-domains tried to refresh from that canonical source as part of its normal operation, found it missing, and failed its copy step after already clearing the destination it was about to repopulate. Dovecot reads its per-domain SSL paths directly, with no symlink layer to fall back through, so a missing file made Dovecot’s config parse fail outright, and Dovecot refused to start at all, for every domain on the box, not just the one being worked on. The fix: populate both locations whenever you’re reusing a cert rather than reissuing, and always dry-run v-rebuild-mail-domains before you let it touch a box carrying live mail for other domains.

A smaller but recurring annoyance: testing with curl -H "Host: <domain>" https://<ip>/ only sets the HTTP Host header, not the TLS SNI, so it will always show you the wrong (default) certificate even when routing is actually fine. Use curl --resolve <domain>:443:<ip> https://<domain>/ instead, which exercises real SNI. And never test against 127.0.0.1; Hestia’s per-domain vhosts are bound to the public IP specifically, so loopback testing falls through to the default cert 100% of the time and will convince you there’s a bug that doesn’t exist.


Leave a Comment