— Artikel — № 129

129 —WordPress

WordPress multisite migreren in één zondag: playbook

Een Plesk-bak op PHP 7.4 met een multisite van 12 sites, een verse VPS bij Hetzner, en een zondag van 09:00 tot 17:00 om alles om te zetten voor maandag.

Bovenaanzicht van geïnkt WordPress multisite migratieplan op ruitjespapier met indexkaarten, manilatab, messingplaat, lakzegel.
Hero · gestileerd stilleven№ 129

Het Plesk-paneel deed het bij de derde poging. PHP 7.4 op de hele bak, MariaDB 10.3, twaalf subsites onder één WordPress multisite-netwerk, de helft gekoppeld aan eigen domeinen. De hostingmaatschappij had in twee maanden drie keer gemaild: de server ging uit support en zou eind van de maand uitgezet worden. De eigenaar van het bureau had zondag tussen 09:00 en 17:00 om het geheel te landen op een verse Hetzner CX22, en dan slapen voor de redactieagenda van maandag.

Dit is het playbook dat we met ze hebben afgewerkt. Acht stappen, één zondag, geen verrassingen op maandagochtend. Het gaat uit van een standaard WordPress multisite (subdomein of submap), root-SSH op de bestemming, en een Plesk-bron die nog levend genoeg is om uit te lezen. Is je bron gedeeltelijk dood, sla dan over naar stap 5 en restore vanaf je laatste goede nachtelijke back-up.

1. Inventariseer de multisite voor je iets aanraakt

Voordat er iets in beweging komt, heb je een schriftelijk overzicht nodig van wat de multisite daadwerkelijk bevat. Plesk verstopt veel state op plekken waar WP-CLI niet komt, en een multisite verstopt nog meer in wp_blogs en wp_site. Open twee terminals: één met SSH op de Plesk-bak, één op de nieuwe VPS.

wp site list --fields=blog_id,url,registered,last_updated
wp option get siteurl --network
wp db query "SELECT domain, path FROM wp_blogs ORDER BY blog_id"
wp plugin list --status=active --network

Dump dat naar een bestand dat je in je migratiemap bewaart. Lijst daarna alle eigen domeinen op die via Plesk's webaliassen of via de WP MU Domain Mapping-plugin zijn gekoppeld, want die mappings staan in wp_domain_mapping en komen niet mee met een gewone bestandskopie. Noteer elke cron die in Plesk's geplande taken staat (vaak een wp cron event run --due-now op een 5-minuten timer), elk mailaccount, elke PHP open_basedir-beperking, en elke .htaccess-override die buiten de WordPress-root leeft.

Sla je deze stap over, dan ontdek je om 16:00 dat subsite 7 wachtwoordresets verstuurde via de lokale mailer van Plesk, en dat er nu niets meer de bak verlaat.

2. Zet de VPS op zoals je hem echt wilt hebben

Weersta de neiging om Plesk te klonen. Je migreert juist om er vanaf te zijn. Een schone Ubuntu 24.04 LTS-bak met nginx, PHP-FPM 8.2, MariaDB 11 en certbot houdt het langer vol dan de komende twee Plesk-verlengingen. Installeer alleen wat je nodig hebt.

apt update && apt install -y nginx mariadb-server certbot \
  python3-certbot-nginx php8.2-fpm php8.2-mysql php8.2-curl \
  php8.2-gd php8.2-mbstring php8.2-xml php8.2-zip php8.2-intl
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar && mv wp-cli.phar /usr/local/bin/wp

Voor multisite op nginx kun je niet leunen op het .htaccess-blok uit de Codex. Je hebt een expliciete server block nodig. De nginx-recepten in de WordPress-documentatie komen het dichtst bij canon. Voor multisite met submappen is het kritieke blok:

map $http_host $blogid {
    default 0;
    include /etc/nginx/wp-multisite-map.conf;
}

server {
    listen 443 ssl http2;
    server_name example.com *.example.com;
    root /var/www/example.com;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    location ~ ^/files/(.*)$ {
        try_files /wp-content/blogs.dir/$blogid/$uri /wp-includes/ms-files.php?file=$1;
        access_log off;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    }
}

Configureer MariaDB voordat je iets importeert. Zet innodb_buffer_pool_size op ongeveer 60% van het beschikbare RAM, zet de default character set op utf8mb4, en maak de database aan met dezelfde naam als de bron. Een mismatch in collation tussen bron en bestemming is de meest voorkomende oorzaak van WordPress database error-regels die pas na de restore opduiken.

3. Schrijfacties blokkeren en de bron bevriezen

Je kunt geen bewegend doel rsyncen. Om 09:00 zet je de multisite in een gecontroleerde onderhoudsstand. De schoonste manier is een netwerkbrede drop-in die een 503 met een Retry-After-header teruggeeft.

<?php
// wp-content/mu-plugins/000-migration-freeze.php
if ( ! is_admin() && ! defined( 'WP_CLI' ) ) {
    header( 'HTTP/1.1 503 Service Unavailable' );
    header( 'Retry-After: 28800' );
    header( 'Content-Type: text/html; charset=utf-8' );
    echo '<h1>Brief maintenance window</h1>';
    echo '<p>We are back online by 17:00 CET.</p>';
    exit;
}

Een 503 met Retry-After is wat Google's crawler verwacht tijdens geplande downtime. Een kale 200 met een onderhoudsboodschap kan pagina's leeg laten herindexeren. Dat detail staat in Google's richtlijn voor het pauzeren van een online business, en het kost je niets om het goed te doen.

Schakel alle WP-Cron-events uit, plus alle Plesk-geplande taken die aan de database komen. Aan de Plesk-kant gaat het sneller via define('DISABLE_WP_CRON', true); in wp-config.php, en de cron-regel in Plesk's geplande taken paneel uitcommentariëren. Bevestig dat WooCommerce, Action Scheduler, en geen enkele back-up plugin nog schrijft.

4. Verplaats bestanden met rsync, niet met zip-and-copy

Een WordPress multisite met twaalf subsites en een paar jaar aan media tikt makkelijk 40 tot 80 GB aan. Het zippen op een krap geconfigureerde Plesk-bak, downloaden, en weer uploaden kost je de hele middag. Gebruik rsync direct tussen hosts, gestart vanaf de bestemming zodat je geen uitgaande SSH vanaf Plesk nodig hebt.

rsync -aHAX --info=progress2 --partial \
  -e "ssh -p 22 -i ~/.ssh/plesk_migration" \
  plesk-user@old.example.com:/var/www/vhosts/example.com/httpdocs/ \
  /var/www/example.com/

De -aHAX-vlaggen behouden hardlinks, ACL's en uitgebreide attributen, en dat doet ertoe voor uploads die door het paneel zijn gechmod'd. Met --partial hervat je als de verbinding wegvalt. Draai het twee keer: de eerste pass kopieert alles, de tweede vangt de handvol bestanden op die tijdens pass één veranderden. Zodra pass twee binnen een minuut klaar is, weet je dat je sync-stabiel zit.

Zet ownership op de bestemming naar de webgebruiker (www-data op Ubuntu) en reset rechten naar 755 voor directories, 644 voor bestanden, met wp-config.php op 640. De WordPress file permissions guide legt uit waarom uploads group-writable moeten zijn en niets anders.

5. Dump de database met de vlaggen die ertoe doen

De defaults van mysqldump locken je tabellen, houden de bak op 100% I/O, en produceren een bestand dat half-correct importeert in een andere MariaDB-versie. Gebruik de vlaggen die er werkelijk toe doen:

mysqldump \
  --single-transaction \
  --quick \
  --routines \
  --triggers \
  --events \
  --default-character-set=utf8mb4 \
  --set-gtid-purged=OFF \
  --no-tablespaces \
  -u wpuser -p wpdb | gzip > /tmp/wpdb-$(date +%Y%m%d).sql.gz

--single-transaction houdt de bron onvergrendeld voor een schema dat alleen InnoDB gebruikt. --quick streamt rijen in plaats van ze te bufferen, en dat doet ertoe zodra een enkele tabel (meestal wp_options of een postmeta-tabel) voorbij de paar honderd MB gaat. --default-character-set=utf8mb4 voorkomt de stille hercodering die emoji en Poolse diakrieten als vraagtekens uit de restore laat komen.

Kopieer de dump naar de bestemming, pak hem uit, en importeer:

scp /tmp/wpdb-*.sql.gz new.example.com:/tmp/
ssh new.example.com
gunzip /tmp/wpdb-*.sql.gz
mysql -u root wpdb < /tmp/wpdb-*.sql

6. Restore, herbedraden en search-replace met WP-CLI

De database staat erop. Nu moet de multisite weten dat hij op een nieuw IP woont, mogelijk op een nieuwe hostname tijdens het testen, en dat de bestandspaden onder wp-content/blogs.dir nog steeds resolven. Bewerk wp-config.php met de nieuwe DB-credentials, de nieuwe DOMAIN_CURRENT_SITE, en deze constanten:

define( 'WP_HOME', 'https://example.com' );
define( 'WP_SITEURL', 'https://example.com' );
define( 'MULTISITE', true );
define( 'SUBDOMAIN_INSTALL', false );
define( 'DOMAIN_CURRENT_SITE', 'example.com' );
define( 'PATH_CURRENT_SITE', '/' );
define( 'SITE_ID_CURRENT_SITE', 1 );
define( 'BLOG_ID_CURRENT_SITE', 1 );

Als je eerst staget op de VPS (aanbevolen: een migrate.example.com-subdomein voor de DNS omklapt), draai dan search-replace per subsite. Multisite zit vol geserialiseerde arrays, dus gebruik WP-CLI in plaats van rauwe SQL.

wp search-replace 'old.example.com' 'example.com' \
  --network --all-tables-with-prefix --precise --skip-columns=guid

wp cache flush
wp rewrite flush --hard

--precise gebruikt PHP's native serialisatie in plaats van een regex, en dat is het enige dat geneste geserialiseerde arrays in wp_options overleeft. --skip-columns=guid is niet onderhandelbaar: de GUID is een permanente identifier, en hem herschrijven breekt feed-abonnees en een handvol plugins die er een hash van maken.

Domain mappings

Gebruikte de multisite de oude MU Domain Mapping-plugin of de moderne ingebouwde mapping, dan verwijst de tabel wp_domain_mapping (of wp_blogs.domain voor de ingebouwde versie) nog naar de DNS van het oude IP. Die rijen hoef je niet aan te passen. Wat je wél moet doen, is bevestigen dat de nginx server_name op de bestemming elk gekoppeld eigen domein bevat. Eén vergeten is het meest voorkomende incident op maandagochtend.

7. DNS omzetten met TTL's die je op vrijdag al hebt verlaagd

Daarom telt vrijdag. Minimaal 48 uur voor het zondag-venster zet je de TTL op elke betrokken A- en AAAA-record op 300 seconden. Tegen de tijd dat je omzet, hebben resolvers wereldwijd caches die binnen vijf minuten verlopen, en niet de 24 uur die je oude NS-records standaard adverteerden.

Op het moment van omzetten werk je het A-record bij, bevestig met dig +short example.com @1.1.1.1 vanaf twee verschillende netwerken, en haal dan Let's Encrypt-certificaten op de nieuwe bak. Gebruik certbot --nginx -d example.com -d www.example.com -d sub1.example.com met elk eigen domein op de commandoregel. ACME's HTTP-01-challenge wil dat DNS al naar de nieuwe bak wijst, dus de volgorde is: DNS, dan certbot, dan de maintenance drop-in weghalen.

8. Controleer het lastige spul: mail, cron, SSL, redirects per subsite

De site laadt. Dat is niet hetzelfde als een werkende site. Loop deze lijst af voordat je het venster afsluit.

  • Mail. WordPress' wp_mail() valt terug op PHP's mail(), en die gaat op een schone VPS nergens heen omdat er geen MTA staat. Installeer msmtp en een echte SMTP-relay (Postmark, Amazon SES, of je bestaande Google Workspace), en test dan met wp eval 'wp_mail("you@you.com","test","ok");' op elke subsite.
  • Cron. Zet WP-Cron weer aan via de system cron, niet de pseudo-cron die bij elke pageload afgaat: * * * * * curl -s https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1.
  • SSL. Bevestig dat elk subsite-domein een geldig cert teruggeeft met curl -vI https://sub.example.com 2>&1 | grep -E "subject|expire". Wildcard-certs zijn verleidelijk, maar verbergen de vervaldatum per host.
  • Redirects per subsite. Custom .htaccess-rewrites van de Plesk-bak zijn niet meegekomen in je nginx-config. Audit ze en vertaal de relevante naar location blocks.
  • Search Console. Dien de property opnieuw in en vraag om een recrawl. De 503 die je netjes hebt geserveerd is gerespecteerd, maar nieuw IP plus nieuwe SSL-fingerprint plus nieuwe server header is genoeg voor Google om opnieuw te willen kijken.

Haal de maintenance drop-in weg, tail twee minuten lang het access log, en let op 500's. Komt er geen, dan is het venster gesloten.

Het kleinste ding dat je vandaag kunt doen

Toen we Pier bouwden, liepen we tegen het deel van dit WordPress multisite-migratie playbook aan dat niemand opschrijft: dat halfuur staren naar een database in phpMyAdmin om uit te vogelen welke rij met geserialiseerde opties de search-replace ging breken. De MySQL editor en versiegeschiedenis bestaan omdat een mislukte search-replace om 16:30 op een zondag het ergste soort onherstelbaar is, en vooruit rollen wint van terugrollen.

Vandaag, voor dit alles in gang wordt gezet: open de Plesk-bak, draai wp site list --fields=blog_id,url,registered,last_updated, en bewaar de output in een bestand. Dat ene bestand is het artefact waar je tijdens de echte zondag zes keer naar terug grijpt, en het is wat je vertelt of je acht stappen voor je hebt of twintig.

— Vragen —

Werkt dit voor een bestemming op een andere MariaDB major-versie?

Ja, als je dumpt met --default-character-set=utf8mb4 en bevestigt dat de collation aan beide kanten klopt. Bij overstappen tussen engines (MariaDB naar MySQL 8) heb je een kolomcheck nodig op de default values van JSON- en TIMESTAMP-kolommen.

Wat als de lokale mailer van Plesk de wachtwoordresets afhandelde?

Daarom hoort mail thuis in stap 8. Zolang WordPress niet op een echte SMTP-relay wijst, verdwijnt elke wp_mail()-aanroep stilletjes. Test met wp eval op elke subsite voordat je de maintenance drop-in weghaalt.

Houdt het playbook stand voor subdomein-multisite?

Ja. Zet SUBDOMAIN_INSTALL op true in wp-config.php en voeg een wildcard-cert toe via de DNS-plugin van certbot. De nginx server_name-regel heeft *.example.com nodig plus elk eigen domein apart vermeld.