
Luglio 2025, audit di stack su una testata della diaspora rumena in Italia. Lo storico del traffico saliva da mesi, il backend WordPress mostrava query MySQL p99 sopra 4 secondi durante i picchi serali e il TTFB medio in PageSpeed era oltre 1,8s. La migrazione testata diaspora rumena è partita da lì, da una scrivania di lavoro condivisa in screen share con il referente tecnico del cliente. Diciotto giorni dopo il sito girava su una piattaforma multi-tenant con redirect 301 di tutto lo storico, infra dedicata e tuning di performance verificato in produzione. Vedi anche gli altri post di questa rubrica: Fieldwork.
Il cliente, anonimizzato
Un quotidiano in lingua diversa dall’italiano per una comunità di lettori in Italia, audience verticale, redazione piccola con cadenza giornaliera. Stack legacy: WordPress su shared hosting di fascia bassa, plugin di traduzione installato anni prima e mai aggiornato a multi-lingua nativo, tema custom forkato due volte, nessuna CDN davanti al backend. Traffico in crescita perché il bacino di lettori si era allargato e i social inviavano picchi non gestibili. Il nome non lo scrivo: in Fieldwork il cliente lo cito solo con consenso esplicito scritto. Qui parlo del lavoro.
Audit: i tre punti che decidono il piano
Prima di toccare codice, due giorni di audit con il referente tecnico, screen share aperto, log e metriche del backend di produzione sotto gli occhi. Tre punti emersi:
- Database non sano. Tabelle
wp_postmetacon righe orfane oltre il 30% del totale, indici mancanti sumeta_keyper le query del tema, autoload caricato a ogni request con valori obsoleti. La querySELECT * FROM wp_options WHERE autoload='yes'restituiva quasi 4 MB di dati per request. - Nessuna CDN, nessun layer di cache HTTP. Origin direttamente esposto, immagini servite senza dimensioni esplicite e senza variant responsive, font caricati da terze parti senza preconnect.
- Plugin di traduzione legacy. Mantenere il bilinguismo richiedeva di estrarre i contenuti come tabelle sorelle, non come custom post types puliti.
L’output dell’audit non era una lista di task, era un piano di migrazione con tre milestone numerate e una finestra di rollback per ogni step.
Architettura target
Multi-tenant editoriale: il sito è entrato come nuovo tenant sopra la piattaforma AI Multisite di Romiltec, con WordPress come CMS frontale e il backend Laravel che gestisce orchestrazione, sync e (in seconda fase) automazione editoriale. Stack lato sito: WordPress in container Docker (PHP 8.3, OPcache, Redis object cache); tema ricostruito su block-editor compatibile, tipografia di sistema, immagini WebP/AVIF con fallback e srcset completo; Nginx davanti al container con micro-cache 60s per archivi e bypass su cookie loggato; Cloudflare in Full Strict TLS con regole su feed e multilingua; MariaDB dedicata, innodb_buffer_pool_size tarato e query_cache_type=0 (il legacy lo aveva ancora attivo, con risultati pessimi).
Il bilinguismo è gestito come due tenant logici sulla stessa codebase, con tenant_id che propaga attraverso queue, cache key e log. Schema WordPress unico, non network multisite: con cinque persone il costo operativo di N database è insostenibile, e lo schema condiviso è risultato più semplice da osservare e da migrare.
La migrazione contenuti
Pipeline in tre stadi. Niente WXR (export nativo XML di WordPress) come unica fonte: il plugin di traduzione legacy segmentava male e perdevamo gli abbinamenti italiano/rumeno.
Stadio 1, dump strutturato. Script PHP CLI sul backend legacy che leggeva il database e produceva un JSON normalizzato per articolo: id legacy, slug, lingua, title, body, autore, categorie, tag, featured image url, embed. Output gzippato, sha256 per integrità.
Stadio 2, normalizzazione. Script Python: rewrite degli URL interni nei body, download delle immagini con retry (lo shared hosting cadeva spesso), normalizzazione dei caratteri (alcuni post avevano caratteri misti UTF-8 e cp1250 dallo stesso editor su browser diverso), generazione varianti WebP/AVIF per le featured.
Stadio 3, import idempotente. Endpoint POST sul nuovo backend che creava o aggiornava il post target via REST API WordPress (Application Passwords, Bearer per il backend Laravel). Idempotenza tramite chiave composita (legacy_id, lingua): rilanciare lo script non duplicava nulla, aggiornava i delta. Timestamp originale preservato. Ogni post atterrava in draft finché un secondo script non lo promuoveva in batch, ordine deterministico per data.
Volumi: decine di migliaia di articoli storici, sette anni di contenuti, decine di GB di media. La pipeline è girata in tre cicli: validazione mapping, bulk, delta del blocco redazione.
Redirect 301: il SEO non si rompe per pigrizia
La parte più sensibile per un cliente editoriale. URL legacy del tipo /2018/03/12/post-name/ dovevano puntare ai nuovi /post-name/ (permalink ridotti, decisione presa con il referente per coerenza con altri siti del bacino). Per ogni post, lo script di import scriveva una riga in una tabella redirect_map sul nuovo backend.
A cutover il file della mappa è stato compilato in regole Nginx native (no plugin redirect, troppo costoso a runtime con 18.000 pattern). Generazione automatica via Jinja2: input la tabella, output /etc/nginx/conf.d/redirects-tenant.conf, reload del processo senza downtime via nginx -s reload. Tre giorni dopo il cutover ho fatto un crawl completo con wget --spider partendo da una sitemap legacy salvata da archive.org più la sitemap del backend storico: zero 404 su URL pubblicati, una manciata di 404 su attachment cancellati anni prima (li abbiamo lasciati 410 Gone, scelta esplicita).
Performance: dalla baseline ai numeri post-migrazione
Misurazioni prese con il referente tecnico in screen share, due strumenti: Google PageSpeed Insights (Lighthouse field data lato mobile) e WebPageTest da nodo Milano, tre run, mediana.
Baseline (giorno zero, sito legacy):
– TTFB mobile p75: 1,84 s
– LCP mobile p75: 4,2 s
– CLS p75: 0,21
– Total page weight homepage: 5,8 MB
Target post-migrazione (giorno 18, infra nuova, stesso URL canonico):
– TTFB mobile p75: 0,42 s
– LCP mobile p75: 1,9 s
– CLS p75: 0,02
– Total page weight homepage: 1,3 MB
I numeri sono questi perché il TTFB lo decide lo stack, non il tema: passare da shared hosting con MySQL al 90% di CPU a un container Docker dietro Nginx micro-cache dimezza il tempo prima di scrivere il primo byte. Il resto è igiene: immagini con dimensioni esplicite, niente font esterni, JS dei consensi caricato in defer.
Workflow post-migrazione e cosa è andato storto
Quindici giorni di lavoro tecnico più tre di affiancamento alla redazione. Sopra il backend WordPress, AI Multisite come hub per pubblicazione assistita (suggerimenti su titoli e meta, non riscrittura), traduzione preservando lo stile (DeepL primario, fallback LLM con istruzioni stilometriche per i pezzi di opinione), check di duplicazione interna prima della pubblicazione. L’AI propone, l’editor decide: regola architetturale del prodotto.
Due intoppi reali. In staging sul campione di mille post zero problemi; in produzione il ciclo si è bloccato a 12.400 perché il lock di scrittura di wp_insert_post collideva con il cron Typesense che indicizzava ogni nuovo post. Disattivato il cron durante l’import bulk, riattivato dopo, indicizzazione full ricostruita dal worker Laravel in background. Secondo: alcune immagini storiche avevano EXIF corrotto (upload via FTP da macchine fotografiche di dieci anni fa), la pipeline AVIF falliva silenziosamente. Fix: try/except esplicito, fallback su JPEG ottimizzato con jpegoptim, log per audit manuale (230 immagini in totale, tutte storiche, nessuna sui post degli ultimi due anni).
I numeri di chiusura
| Metrica | Valore |
|---|---|
| Giorni di lavoro effettivo | 18 |
| Articoli migrati | decine di migliaia |
| Media migrati | decine di GB |
| Redirect 301 generati | oltre 18.000 |
| Riduzione TTFB p75 | da 1,84s a 0,42s |
| Riduzione LCP p75 | da 4,2s a 1,9s |
| Riduzione page weight homepage | da 5,8 MB a 1,3 MB |
| Persone Romiltec coinvolte | 2 (un senior dev, io sul DevOps e sul piano) |
Quello che mi sono portato a casa
Prima. Un audit di due giorni vale quanto due settimane di sviluppo cieco. Le tre cose che servono al sito le vedi solo se apri lo screen share e guardi le metriche reali, non se leggi il brief.
Seconda. I redirect 301 si fanno bene una volta, in compilazione, non a runtime. 18.000 pattern in un plugin redirect costano millisecondi a request: gli stessi 18.000 in nginx.conf con ngx_http_rewrite_module costano zero (lookup hash). È la differenza fra una migrazione che lascia il sito veloce e una che lo lascia lento per pigrizia.
Terza. Una migrazione editoriale non è un singolo deploy, è una pipeline idempotente che può girare tre volte senza romperti niente. Il primo round serve a validare, il secondo a portare il bulk, il terzo a recuperare i delta del blocco redazione. Se il tuo script non è idempotente, non hai una migrazione: hai una scommessa.
Diciotto giorni, due persone, un cliente che non si accorge del cutover (era previsto un blocco di redazione di 90 minuti, è durato 38). Il pezzo che ho imparato da questo lavoro non è tecnico: è che le metriche pre/post sono il vero output di una migrazione. Se non le hai prese al giorno zero, non sai cosa hai consegnato.
