117 —Drupal
Drupal 7 naar static op Cloudflare Pages: 2-uur draaiboek
Drupal 7 is end of life. Je hebt er eentje geërfd. Hier is een route van twee uur naar een static export op Cloudflare Pages met het contactformulier nog werkend.
De site is een Drupal 7-build uit 2014 voor een regionaal advocatenkantoor. Ongeveer 140 nodes, een nieuwssectie die niemand bijhoudt, een partnergrid, vier landingspagina's en één contactformulier dat aanvragen nog altijd naar een gedeelde inbox stuurt. De hostingrekening is €38 per maand voor een VPS die sinds 2022 geen patches meer heeft gezien. De klant wil ervan af, maar het mag niet stuk. Je hebt een middag.
Dit is het draaiboek dat we draaien als een Drupal 7-site van het infuus af moet zonder een echte rebuild. De eindsituatie: een static export op Cloudflare Pages, het contactformulier dat naar een kleine worker post, en de originele verouderde site gearchiveerd als tarball die je weer kunt opstarten als iemand erom vraagt. Twee uur, plus een koffie.
De vorm van de migratie
Drupal 7 is op 5 januari 2025 end of life gegaan. Security advisories zijn gestopt. PHP 7.4, waar de meeste D7-sites nog op draaien, krijgt sinds november 2022 geen fixes meer. Het dreigingsmodel is dus echt: elke unauthenticated RCE in een contrib-module is nu een permanent openstaande deur. De klus is niet om Drupal te moderniseren. De klus is om de HTML van de stervende server te halen en te behouden wat de klant daadwerkelijk gebruikt.
Voor een brochuresite met een contactformulier valt dat uiteen in vier stappen:
- Crawl de gerenderde site naar platte HTML.
- Herschrijf het contactformulier zodat het naar een worker post.
- Publiceer op Cloudflare Pages met de juiste redirects.
- Archiveer de database en de Drupal-root zodat je kunt aantonen wat je hebt gemigreerd.
Elke stap heeft één addertje. We gaan ze op volgorde af, met een inventarisatie van vijf minuten vooraf en een verificatieronde vóór de cutover.
De inventarisatie van vijf minuten
Vijf minuten in de admin scheelt je later een uur verwarde recrawls. Log in via /?q=user en beantwoord vier vragen op een kladblok. Welke contrib-modules staan aan die de gerenderde output beïnvloeden: Views, Panels, Webform, alles wat content uit query-parameters trekt? Welke content types hebben body-velden met embedded media: YouTube-iframes, inline referenties naar /sites/default/files, oEmbed-kaarten? Gebruikt het thema Drupals image styles voor responsive images, en welke derivative-formaten noemt de gerenderde HTML? Zijn er formulieren naast het contactformulier: een nieuwsbriefaanmelding, een sollicitatieformulier, een zoekveld dat een Views-endpoint aanroept?
Niets hiervan is paranoia. Het is een kaart van wat de crawl wel en niet meeneemt. Een Views-pagina met exposed filters geeft je de gefilterde toestanden niet, tenzij je de filter-URL's met de hand opsomt. Webform-inzendingen hebben dezelfde worker-rewrite nodig als het contactformulier, en ze zijn makkelijk te missen omdat ze vaak op werken-bij- of offerte-pagina's staan waar de klant niet meer aan dacht. De map /sites/default/files bevat elke geüploade afbeelding en PDF op de site; als die buiten de crawl-scope valt, lever je een site vol gebroken assets en heeft de klant het binnen een uur door.
Bewaar de antwoorden in een tekstbestand van vier regels naast het project. Je raadpleegt het de komende twee uur tweemaal, en over achttien maanden nog eens als iemand vraagt waarom een bepaalde pagina nog op die manier resolvet.
Stap 1 — crawl de gerenderde site, niet de source
De verleiding is om node--page.tpl.php erbij te pakken en de templates met de hand opnieuw op te bouwen. Doe het niet. De snelste route is om de live site te crawlen zoals een browser dat zou doen en de gerenderde HTML te dumpen. wget doet dit nog steeds beter dan alles wat dit decennium geschreven is.
wget \
--mirror \
--convert-links \
--adjust-extension \
--page-requisites \
--no-parent \
--reject-regex '(\?|/user/|/admin/|/node/add)' \
--user-agent='Mozilla/5.0 static-export' \
--wait=0.3 --random-wait \
https://oldsite.example.nl/
Een paar dingen om te weten voor je enter drukt. --convert-links herschrijft absolute URL's naar relatieve paden, wat is wat je wilt voor Pages. --adjust-extension maakt van /about een /about.html, en dat doet ertoe voor de redirect-regels straks. De --reject-regex gooit query-string-varianten van dezelfde pagina weg (Drupal exposeert graag ?page=1) en de admin-paden die niemand anoniem zou moeten benaderen.
Draai het. Zet koffie. Kom terug bij een directorystructuur die de site spiegelt.
Output opschonen
De crawl laat je achter met Drupal-eigenaardigheden in de HTML: jquery.once.js, drupal.js, de Drupal.settings-blob, CSS-aggregatiebestanden met namen als css_8f2a...css. Het meeste is onschadelijk op een static host. Wat het snoeien waard is, is het inline <script>-blok dat Drupal.settings definieert; dat lekt meestal het basePath, het theme-path en soms moduleconfiguratie die de stack verraadt.
find . -name '*.html' -exec sed -i '' \
'/Drupal\.settings/,/};/d' {} +
Op Linux laat je de lege '' na -i weg. Dit is zo'n edit waarbij je een paar bestanden wilt diffen voor je de regex vertrouwt.
Stap 2 — het contactformulier, herschreven
Het oude formulier post naar /contact en leunt op Drupals form API om te valideren, mail te versturen via drupal_mail() en door te sturen naar een bedankt-node. Niets daarvan overleeft de export. Je hebt drie dingen nodig op de nieuwe stack: een plek om naar te POSTen, een manier om de mail te versturen, en een bedankt-pagina.
Een Cloudflare Worker doet de eerste twee in een dertigtal regels. Bind hem aan /api/contact op hetzelfde Pages-project en het formulier verlaat de origin niet, wat de browser blij houdt over CORS en de klant blij houdt omdat hij geen derde-partij-domein op zijn site hoeft te zien.
export default {
async fetch(request, env) {
if (request.method !== 'POST') return new Response('Method not allowed', { status: 405 });
const form = await request.formData();
const name = (form.get('name') || '').toString().slice(0, 200);
const email = (form.get('email') || '').toString().slice(0, 200);
const message = (form.get('message') || '').toString().slice(0, 5000);
const honeypot = (form.get('website') || '').toString();
if (honeypot) return Response.redirect('https://example.nl/thanks/', 303);
if (!email.includes('@') || message.length < 10) {
return new Response('Invalid submission', { status: 400 });
}
await fetch('https://api.postmarkapp.com/email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Postmark-Server-Token': env.POSTMARK_TOKEN,
},
body: JSON.stringify({
From: 'web@example.nl',
To: 'info@example.nl',
Subject: `Contact form: ${name}`,
TextBody: `From: ${name} <${email}>\n\n${message}`,
MessageStream: 'outbound',
}),
});
return Response.redirect('https://example.nl/thanks/', 303);
},
};
Patch daarna de gecrawlde HTML zodat het formulier naar de worker wijst en Drupals tokens kwijt is.
find . -name '*.html' -exec sed -i '' \
-e 's|action="/contact"|action="/api/contact"|g' \
-e '/name="form_build_id"/d' \
-e '/name="form_token"/d' \
-e '/name="form_id"/d' {} +
Voeg met de hand een honeypot-veld toe aan de contact-template — een hidden input genaamd website — en houd de bestaande veldnamen aan, zodat je de inboxfilters die de klant in tien jaar heeft opgebouwd niet hoeft te hertrainen.
Stap 3 — publiceren, met de redirects die ertoe doen
Zet de gecrawlde map in een git-repo, push hem, wijs Cloudflare Pages erop. Het build-commando is leeg; de output directory is .. Dat deel kost vier minuten.
De rest van stap 3 zit in het redirect-bestand. Drupal-sites stapelen jarenlang URL-aliassen op, en de crawl gaf je .html-extensies die je niet zichtbaar wilt hebben. Cloudflare Pages leest een _redirects-bestand in de root.
/node/12 /diensten/ 301
/node/47 /over-ons/ 301
/contact /contact/ 301
/*.html /:splat/ 301
/sites/default/files/* /assets/:splat 301
De /node/*-regels zijn degene die je een week later bijten als je ze overslaat. Oude inkomende links uit LinkedIn-posts en PDF's komen nog altijd op /node/12 uit, en zonder een mapping krijgen ze stilzwijgend een 404. Je kunt de aliassentabel uit Drupal dumpen voor je hem afbreekt:
SELECT CONCAT('/', source) AS old,
CONCAT('/', alias, '/') AS new
FROM url_alias
ORDER BY pid;
Plak de output in _redirects, zet 301 voor elke regel, en je hebt de longtail afgedekt. De redirect-docs van Cloudflare zijn een korte blik waard; er zit een limiet van 2.000 regels per bestand op, ruim genoeg voor een brochuresite maar goed om te weten.
Verifieer voor je de DNS omzet
De verleiding na stap 3 is om de A-record om te zetten en het af te tikken. Doe het niet. De static deployment gedraagt zich op vijf punten anders dan Drupal, en je kunt ze allemaal in een kwartier nalopen; elk ervan kan je voor schut zetten bij de klant.
Eén: zet de TTL van het bestaande record op 300 seconden, minstens 24 uur voor de geplande cutover. Sla je dit over en moet je terugrollen, dan zit je vast aan de TTL die de registrar oorspronkelijk heeft gezet — meestal een dag, soms langer. De uitleg van Cloudflare over TTL-gedrag is vijf minuten lezen waard als het al even geleden is.
Twee: draai een link-check tegen de Cloudflare Pages preview-URL voor je DNS omzet. wget --spider --recursive --no-verbose tegen de preview-deployment print elke 404 en 500 die hij tegenkomt; pipe de output door grep -E 'broken|failed' en je hebt een eindige to-do-lijst in plaats van een vaag gevoel.
Drie: dien het contactformulier zelf in met een echte payload, vanaf een telefoon op mobiele data zodat je geen cached response krijgt vanuit je kantoor-IP. Hou de inbox in de gaten. Als de worker stilzwijgend mail laat vallen omdat Postmark het From-adres als ongeverifieerde sender signature afwijst, wil je dat eerder weten dan de klant.
Vier: sample de redirects met curl -sI. De homepage, de contactpagina, drie willekeurige /node/*-URL's uit de oude aliasdump. Je zoekt een 301 met een Location-header die naar de nieuwe alias wijst, niet een Pages 404 in vermomming.
Vijf: bekijk de bron van de gedeployde pagina's in een browser. De Drupal.settings-blob die je in stap 1 hebt verwijderd is de voor de hand liggende verklikker, maar sommige D7-thema's bakken ook de username van de redacteur of een last-edit-timestamp in als HTML-comment. Strip alles wat je niet wilt dat de developer van een concurrent leest.
Stap 4 — archiveer het origineel zodat het terug te halen is
Voor je de VPS opzegt, leg je twee artefacten vast. Eén mysqldump van de Drupal-database, één tarball van de docroot. Zet beide op cold storage met een README erbij die vermeldt op welke PHP- en MySQL-versies ze draaiden. Dit is het stukje dat mensen overslaan en achttien maanden later betreuren, als een bestuurslid vraagt om een persbericht dat op de oude nieuwssectie stond.
mysqldump --single-transaction --routines --triggers \
-u drupal -p drupal7db | gzip > drupal7-final.sql.gz
tar --exclude='sites/default/files/styles' \
-czf drupal7-docroot.tar.gz /var/www/oldsite
De map styles bevat Drupals derivative-images: thumbnails, geschaalde versies, de crops die het thema on the fly genereert. Ze worden uit de originelen opnieuw aangemaakt, dus door ze uit te sluiten scheelt dat een paar honderd megabyte op het archief. Houd de dump en de tarball samen in één map, vernoemd naar de cutover-datum en het oorspronkelijke domein; jij-over-een-jaar herinnert zich geen van beide tegen de tijd dat iemand ernaar vraagt.
Wat dit oplevert
Een middag werk verplaatst de site van een kwetsbare PHP 7.4-bak naar een static deployment met TLS, CDN en DDoS-bescherming voor €0 per maand bij de volumes die een regionaal advocatenkantoor ziet. Het contactformulier werkt nog. De URL's resolven nog. De klant merkt niets, behalve dat de site sneller is geworden.
Wat je opgeeft is het CMS. Als de klant vaker dan eens per kwartaal content bewerkt, is dit de verkeerde keuze en moet je naar een echte rebuild kijken. Voor iedereen anders — en dat zijn er veel, zittend op Drupal 7-sites die sinds 2019 niet zijn aangeraakt — is de static export het eerlijke antwoord.
Toen we Pier bouwden was het lastige aan dit soort klussen altijd de databasekant: rondneuzen in url_alias, node en field_data_body om uit te vogelen wat er nou werkelijk op de site staat voor je gaat crawlen. Wat we uiteindelijk hebben gedaan is een ingebouwde MySQL editor naast de bestandsboom dokken, met version history op elke query, zodat je tegen de live database kunt experimenteren zonder een aparte dump in een tweede venster open te hoeven houden.
Het kleinste wat je vandaag kunt doen: draai wget --mirror tegen een van de slapende Drupal-sites in je portfolio, gewoon om te zien hoe schoon de output is. Dat alleen al vertelt je welke klanten een klus van twee uur zijn en welke een echt gesprek nodig hebben.
— Vragen —
Werkt het contactformulier nog na de static export?
Ja, als je het naar een kleine Cloudflare Worker routeert die de validatie afhandelt en de mail via Postmark of iets vergelijkbaars verstuurt. De Drupal-formuliertokens worden tijdens de crawl gestript.
Hoe zit het met oude /node/123-URL's vanuit externe links?
Dump de url_alias-tabel uit MySQL voor je Drupal afbreekt, en schrijf elk oud pad als een 301 in het _redirects-bestand op Cloudflare Pages. Daarmee pak je de longtail mee.
Kan ik na de migratie nog content blijven bewerken?
Niet zonder rebuild. De static export is de juiste keuze voor sites die zelden worden bewerkt. Werkt de klant maandelijks of vaker bij, kijk dan naar een echte CMS-migratie.