
Ottobre 2023. Un editore italiano regionale, una rivista verticale con piccola redazione e centinaia di migliaia di visite/mese, ci ha chiamati con una richiesta secca: chiudere il piano WordPress.com Business e passare su infra dedicata gestita da Romiltec, in due settimane. Avevano sbattuto la testa contro tre limiti del piano Business: niente plugin server-side fuori da una whitelist, niente accesso al filesystem, niente possibilità di tunare nginx o cache layer. Il loro plugin di paywall custom era bloccato e la velocità di Discover li stava prendendo a sberle. Undici giorni di lavoro effettivo dopo, erano in produzione su un nostro nodo dedicato. Per altri casi simili: Fieldwork.
Il punto di partenza
WordPress.com Business non è WordPress self-hosted. È un PaaS che assomiglia a WordPress: stessa interfaccia editor, stessa app mobile, stesso modello mentale per la redazione, ma sotto è una piattaforma chiusa con plugin whitelisted e un layer di cache invalidabile solo dal supporto Automattic. Quando il cliente ha bisogno di:
- un plugin di paywall custom scritto in PHP che hooka su
the_contente suwp_authenticate - accesso al filesystem per allineare un microservizio Python che genera embedding semantici delle bozze
- override nginx per servire correttamente i feed RSS con cache più aggressiva di quella default
- log raw degli accessi per analytics interna su ClickHouse
il piano Business non è la risposta giusta. Smetti di pagare la piattaforma e ti porti via le chiavi di casa.
L’export di WordPress.com lo conosco bene: sotto Tools → Export ti danno un XML con post, pagine, commenti, categorie, tag, custom post types se ne hai, e media. I media sono URL CDN di wp.com/wp-content/uploads/, non file. Il primo lavoro è ribaltare i media dall’XML al filesystem locale del nuovo server.
Giorno 0 e 1: discovery e setup ambiente
Il giorno zero è stato una call di un’ora e mezza con il caporedattore e il loro dev part-time. Screen share aperto sul backend WordPress.com, log delle ultime sei settimane, conteggio dei post (~14.000), conteggio dei media (~58.000 immagini), inventario plugin attivi sul piano Business (otto plugin tutti dalla whitelist Jetpack-family), schema custom fields usati da paywall vecchio (gli unici tre meta che dovevamo migrare con cura).
Il giorno uno, server. Hetzner AX52 in Germania, Debian 12, Hestia Control Panel installato con lo stampino standard (vedi il post sull’infra Hestia di Romiltec), pool php-fpm 8.2, nginx con fastcgi_cache configurato per WP, Redis locale per object cache, MariaDB locale, Cloudflare DNS già pronto sul dominio (delegato come zona ma con record originali ancora in puntamento WordPress.com, in modo da poter switchare quando volevamo).
Sul nodo: utente Hestia dedicato, dominio aggiunto come staging, certificato Let’s Encrypt sul subdomain staging.<editore>.it, installazione WordPress 6.3, theme custom del cliente esportato come zip dal loro repo Bitbucket privato.
Giorno 2 e 3: import e media
L’XML WordPress.com pesava 480 MB. Il plugin WordPress Importer di base soffoca su file di quella taglia: timeout PHP, memoria, lock di MySQL. Soluzione: WP-CLI.
wp import wxr-export.xml --authors=create --skip=image_resize
Niente resize on-the-fly: prima carico tutto, poi rigenero le size con wp media regenerate in background. L’import è durato quattro ore con --quiet e progress bar custom basato su pv davanti allo stdin. Niente errori. Il --skip=image_resize è la chiave: WP Importer di default cerca di scaricare ogni media URL e generare le size del theme. Con 58.000 immagini su CDN remoto è suicidio.
I media veri li ho scaricati con uno script Python multi-thread (10 worker, retry esponenziale, cache locale degli URL già scaricati). Quattro ore di download anche quella. Output: decine di GB di immagini nella directory uploads di WordPress.
Per riallineare gli URL nel DB ho usato wp search-replace con --dry-run prima e poi senza:
wp search-replace 'https://<editore>.wordpress.com' 'https://staging.<editore>.it' \
--skip-columns=guid --report-changed-only
I guid non li tocco: WordPress li usa come ID semantico dei feed RSS, sostituirli rompe i reader esterni. La regola di Mark Jaquith su WordPress.org Codex vale ancora.
Giorno 4: il theme e il plugin paywall
Il theme custom era un fork di un Genesis child theme con una decina di template page modificati. Lo abbiamo committato in un nostro repo GitLab privato, deploy via Deployer (vedi project_respect_deployment_frameworks per il pattern), public_html/current come symlink al release attivo.
Il plugin paywall: 1.200 righe di PHP, hookato su template_redirect per intercettare richieste a post premium, su wp_authenticate per validare token JWT firmati col loro provider Stripe, su the_content per troncare il body al paywall. Sul piano Business non era stato accettato (richiedeva di scrivere su wp_options con autoload high-cardinality che WordPress.com filtra). Sul nostro nodo dedicato: si attiva, si configura, gira. Tempo di QA con il dev del cliente in screen share: due ore. Trovati tre bug minori (dovuti al fatto che il loro test environment era WP 5.x e noi eravamo già su 6.3): patch immediata, deploy nuovo release.
Giorno 5 e 6: performance tuning
A questo punto il sito girava su staging, ma con tempi di risposta medi di 1.4s sulla home, accettabile ma non eccellente. Lavoro di tuning:
- nginx
fastcgi_cachecon TTL 5 minuti su tutto il pubblico, bypass suwp-admin,wp-login.php, cookie loggati, query string?context=edit - header
X-FastCGI-Cache: $upstream_cache_statusper debug - Redis come object cache via plugin
redis-cache(Till Krüss), conWP_REDIS_DATABASEdedicata - preload critici sui font del theme (due weight di Inter)
wp transientcleanup schedulato con cron ogni notte- compressione brotli a livello nginx, fallback gzip
TTFB dopo tuning: 180ms cached, 420ms uncached. LCP (Largest Contentful Paint) sotto i 2 secondi su 4G simulato. CLS sotto 0.05. CWV verdi su PageSpeed Insights, dato che faceva felice il caporedattore (perché Discover premia i CWV verdi).
Giorno 7 e 8: redirect, sitemap, search console
WordPress.com aveva permalink /%year%/%monthnum%/%day%/%postname%/. Il cliente voleva passare a /%postname%/ per pulire URL e migliorare CTR. Ma cambiare permalink struttura su 14.000 post pubblicati significa generare 14.000 redirect 301.
Soluzione: tabella redirects custom in DB con due colonne (old_path, new_path), popolata con uno script PHP che itera su tutti i post:
foreach (get_posts(['numberposts' => -1]) as $post) {
$old = '/' . date('Y/m/d', strtotime($post->post_date)) . '/' . $post->post_name . '/';
$new = '/' . $post->post_name . '/';
DB::insert('redirects', ['old_path' => $old, 'new_path' => $new]);
}
Lookup in nginx via map (per i top 1000 redirect, statici) + fallback su un endpoint PHP minimale per il resto. Tutti 301 Moved Permanently.
Sitemap rigenerata con Yoast SEO (poi sostituito da Rank Math nel 2024) e sottomessa su Google Search Console del nuovo dominio. Property aggiunta sia per https://<editore>.it che per https://www.<editore>.it. Change of address tool di Search Console: saltato, perché il dominio root era lo stesso. Cambiavano solo i path interni (gestiti via 301).
Giorno 9: DNS cutover
Mattina del giorno 9, ore 06:00. TTL dei record DNS di Cloudflare già abbassato a 300 secondi due giorni prima. Sequenza:
- Ultimo
wp search-replacedastaging.<editore>.ita<editore>.itsul DB - Modifica del dominio principale su Hestia (
v-change-web-domainper riallineare il vhost) - Switch dei record A su Cloudflare dal puntamento WordPress.com a quello del nuovo nodo
- Cache fastcgi flush completa
curl -sI https://<editore>.it/per validare HTTP 200 + cache MISS al primo hit, HIT al secondo- Monitoring Cloudflare analytics e Matomo locale per i primi 30 minuti
Downtime osservato dai monitor (UptimeRobot esterno + check interno via Prometheus blackbox): 48 secondi, finestra in cui i resolver upstream stavano ancora servendo il vecchio IP cacheato.
Giorno 10 e 11: traffic check e cleanup
Le 48 ore successive al cutover sono cruciali per un sito editoriale, perché Discover e Google News leggono le sitemap nuove e devono trovare ogni URL accessibile. Il monitoring puntava su tre cose:
- traffico organico (Matomo): livello pre-migrazione mantenuto entro il 95% nei primi 7 giorni, ridotto al 92% nei giorni 14, recuperato al 100% al giorno 21
- 404 (nginx access log + dashboard custom): zero 404 sui top 500 URL precedenti, qualche 404 marginale su URL legacy mai indicizzati, redirect aggiunti puntualmente
- crawl errors (Search Console): nessun errore strutturale segnalato dopo 72h
Giorno 11, ultima call con caporedattore e dev part-time del cliente: sign-off, fattura, cliente passato da un piano WordPress.com fascia Business a un nodo Hetzner dedicato + manutenzione Romiltec con un costo totale annuo nettamente inferiore e con un controllo che prima non aveva.
Cosa è andato bene e cosa farei diversamente
Bene: lo stampino HestiaCP standardizzato. Avere un’infrastruttura ripetibile ha trasformato il giorno 1 in una mezza giornata di lavoro invece di tre. Bene anche il pattern staging cloacked sul subdomain prima del cutover: ci ha permesso di fare QA reale per cinque giorni senza che Google indicizzasse mai il subdomain (Disallow: / e noindex finché non era ora di switchare).
Diversamente: il giorno della discovery avrei dovuto chiedere subito i log raw del CDN WordPress.com per validare il comportamento dei feed RSS prima della migrazione, non dopo. Un feed reader esterno ha avuto due settimane di doppio fetch perché un campo <guid isPermaLink="false"> è stato interpretato male. Fix banale, ma evitabile.
Non si può migrare un sito editoriale in undici giorni senza tre cose: una stack standardizzata, WP-CLI usato seriamente, e un cliente che capisce la differenza tra staging e produzione. Il cliente in questione la capiva perfettamente. Per loro è stata la migrazione più indolore della loro storia. Per noi, il primo grosso case study di migrazione editoriale che è poi diventato uno schema applicato ad altri quotidiani locali e riviste verticali nei mesi successivi.
