Migration
Van Drupal 7 naar Strapi 5: cutover met shadow traffic
Acht weken, twee stacks naast elkaar, 26.400 IIIF-links die in leven moeten blijven en een KB-harvester die geen enkele OAI-PMH-update mag missen. Zo verliep de cutover.

Het is maart 2026, een dinsdagochtend in Haarlem. De hoofdredacteur van een cultureel-erfgoed-uitgever met 24 medewerkers opent het collectie-portaal op haar laptop. Het laadt in 4,2 seconden, net zo snel als in 2014. Het community-LTS-venster van Drupal 7 sloot op 5 januari. De PHP 7.0-omgeving die haar infrabeheerder nog in de lucht wilde houden, loopt eind mei af. De KB-harvester pollt haar OAI-PMH-endpoint elke zes uur en heeft in negen jaar geen update gemist. Ze heeft 24 medewerkers, 1.180 onderzoekers met een actief account en over twee weken een bestuursvergadering waarin ze zich aan een getal moet committeren.
Dat getal werd acht weken.
De staat van het Drupal 7-portaal
Het portaal werd in 2012 gebouwd door twee externen, in 2016 overgedragen aan een derde en sinds 2019 in leven gehouden door één interne ontwikkelaar. In 2026 droeg het:
- 18.400 collectie-objecten als Drupal-nodes, elk met een custom veld dat een IIIF-manifest-URL bevat die naar een externe Hyrax-image-server wijst.
- 26.400 uitgaande
iiif_manifest-referenties in totaal — sommige objecten droegen er twee of drie. - Een
raadpleeg_history-tabel buiten het Drupal-schema, gevuld door een custom module bij elke node-view door een ingelogde onderzoeker. 4,1 miljoen rijen. - Een OAI-PMH-endpoint op
/oaibediend door de al jaren niet meer onderhoudenoai_pmh-contribmodule, elke zes uur door de KB geharvest. - 312 actieve redactiegebruikers, 1.180 onderzoeker-accounts, 90 SAML-logins via de regionale erfgoed-federatie.
PHP 7.0. MariaDB 10.3. Een negen jaar oude, gepatchte CKEditor. Eén cronjob die sinds 2017 niet meer was aangeraakt.
Waarom Strapi 5 en Astro, geen nieuwe monoliet
Een 14 jaar oud Drupal-portaal vervangen door een nieuwe monoliet in dezelfde vorm zou de uitgever nog een decennium hetzelfde probleem hebben opgeleverd. We splitsten het systeem op de voor de hand liggende naad: een headless CMS voor de redactie, een statische front-end voor lezers en een dunne Node-service voor de delen die dynamisch moeten blijven (zoeken, raadpleeg-history-writes, OAI-PMH).
Strapi 5 omdat de redactie een Nederlandstalige admin-UI, lifecycle hooks en componentgebaseerde contentmodellering nodig had. Astro omdat het publieke portaal voor 95% leesverkeer is, het SEO-oppervlak groot is (elk collectie-object is een landingspagina) en de enige interne ontwikkelaar al TypeScript kende. De dynamische naden draaiden op een Node/Fastify-service die we portaal-edge noemden, naast Strapi gedeployed.
Het schema van acht weken
Week 1, contentmodellering en export-schema definitief vastleggen. Week 2, extractiepipeline Drupal → Strapi, read-only en idempotent. Week 3, Astro-shell, IIIF-viewer aansluiten, zoekfunctie. Week 4, raadpleeg-history ingest, SAML, onderzoeker-accounts. Week 5, OAI-PMH-pariteit en KB-regressie. Week 6, shadow traffic op 10%, 25%, 50%. Week 7, shadow traffic op 100% met beide stacks live. Week 8, cutover, Drupal bevroren, archief-snapshot.
De eerste drie weken zijn de goedkope weken. In week 4 tot en met 7 sneuvelt elke migratie van dit kaliber.
26.400 IIIF-manifest-links
De IIIF-manifesten staan op een externe image-server die de uitgever niet beheert. De links zijn stabiel, maar de manier waarop Drupal ze opsloeg niet: sommige waren absoluut, sommige relatief aan een base-URL uit een variable_get, en een paar honderd hadden dubbel-encoded query strings uit een batch-import uit 2018.
We normaliseerden in de extractiestap, niet in de front-end. Eén regex en één URL-parse per rij, met het originele Drupal-veld ernaast bewaard als iiif_manifest_legacy zodat we later konden diffen. De normaliser had één doel: een URL produceren die de IIIF Presentation API 3.0-client zonder redirect kon resolven.
// extractors/iiif.ts
import { URL } from 'node:url'
const BASE = 'https://images.example.nl/iiif/'
export function normaliseManifest(raw: string): string {
if (!raw) throw new Error('empty manifest')
const decoded = raw.includes('%25') ? decodeURIComponent(raw) : raw
const absolute = decoded.startsWith('http')
? decoded
: new URL(decoded, BASE).toString()
const u = new URL(absolute)
// strip Drupal's cache-buster query that nobody asked for
u.searchParams.delete('_dc')
return u.toString()
}
Elk geëxtraheerd object ging door een verifier die een HEAD-request op de manifest-URL deed en de responsstatus ernaast opsloeg. We draaiden de hele set op een zaterdagavond. 26.338 gaven 200, 51 gaven 404, 11 redirectten. De 404's losten we met de archivaris met de hand op voordat week 4 startte.
Raadpleeg-history per onderzoeker
De raadpleeg-history was het onderdeel dat niemand wilde verliezen. Sommige entries gaan terug tot 2014. Onderzoekers citeren hun eigen raadpleeg-history in academische artikelen. Rijen laten vallen zou een vertrouwensbreuk veroorzaken die geen UI-verbetering kon repareren.
We verplaatsten de tabel verbatim, schema en al, naar een Postgres 16-instance achter portaal-edge. Het write-pad verhuisde van een Drupal-hook_node_view naar een Fastify-route die de Astro-client na first paint aanroept.
// portaal-edge/routes/raadpleeg.ts
app.post('/raadpleeg', async (req, reply) => {
const { object_id } = req.body as { object_id: string }
const onderzoeker = await requireOnderzoeker(req) // SAML session
await db.query(
`insert into raadpleeg_history
(onderzoeker_id, object_id, geraadpleegd_op, source)
values ($1, $2, now(), 'astro-v1')`,
[onderzoeker.id, object_id]
)
return reply.code(204).send()
})
De source-kolom is de truc. Elke verouderde rij draagt source = 'drupal-v7'. Elke nieuwe rij draagt source = 'astro-v1'. Tijdens shadow traffic schreven beide systemen parallel, en we reconcilieerden aan het eind van elke dag met één select count(*) ... group by source, date_trunc('day', geraadpleegd_op). Toen de aantallen drie dagen op rij binnen 0,2% tolerantie matchten, knipten we de Drupal-writer eruit.
OAI-PMH-feed naar de KB
De KB-harvester is het onverzettelijke object. Hij heeft maling aan je migratie. Hij verwacht dat verb=ListRecords Dublin Core XML teruggeeft, gepagineerd via resumptionToken, met een stabiele identifier per record die sinds 2014 niet veranderd is.
We bouwden het endpoint opnieuw binnen portaal-edge rechtstreeks tegen de OAI-PMH 2.0-specificatie, en genereerden vanuit Strapi-content in plaats van vanuit een contribmodule. Twee dingen telden: het identifier-schema moest matchen (oai:erfgoed.example.nl:object:{drupal_nid} — ja, we bewaarden de Drupal node-ID's als kolom in Strapi), en de datestamp moest de changed-timestamp van het originele record gebruiken, niet de migratietimestamp.
We draaiden het nieuwe endpoint een week lang naast het oude. De KB harvestte beide. We diffden de resultsets na elke harvest. Op dag vijf matchten ze exact.
Als je tijdens een migratie OAI-PMH-identifiers herschrijft, behandelt elke downstream harvester (de KB, Europeana, regionale aggregators) elk record als nieuw. Je genereert miljoenen valse ‘updates’ en je relatie met de aggregator wordt snel luidruchtig. Hou de identifiers, ook als ze er lelijk uitzien.
De mechanica van shadow traffic
Shadow traffic was in onze opzet een Caddy reverse proxy voor het publieke portaal, ingesteld om een percentage van de GET-requests te mirroren naar de nieuwe Astro-stack en de response weg te gooien. De gebruiker kreeg nog steeds de Drupal-response te zien.
erfgoed.example.nl {
reverse_proxy drupal-app:80
@shadow {
method GET
expression {http.request.uri.path}.matches("^/(collectie|object|zoek)")
}
handle @shadow {
reverse_proxy drupal-app:80
# fire-and-forget mirror to the new stack
reverse_proxy /__mirror astro-app:3000 {
lb_policy first
health_uri /healthz
}
}
}
De mirror-request droeg de originele request-ID in een X-Shadow-Request-header. De Astro-stack logde zijn responscode, latency en gerenderde byte count. Die vergeleken we 's nachts met de Drupal-logs. Aan het eind van week 6 zat de nieuwe stack binnen 8% van Drupals p95-latency op gecachte pagina's en was hij 40% sneller op cold pagina's.
Cutover-dag
Week 8, dinsdag, 06:00 CET. De redactie was gebrieft op een schrijfpauze van 90 minuten. We zetten het DNS A-record op de Astro-stack, schakelden Caddy van ‘mirror’-modus naar ‘primary nieuw, mirror oud’ en keken naar de access logs.
Het eerste dat brak was een bookmarklet die een conservator sinds 2015 gebruikte en die een verouderde /node/edit/{id}-URL aanriep. We voegden een 301 toe van /node/edit/* naar het Strapi-admin-equivalent. Twaalf minuten.
De KB-harvester draaide om 12:00 CET, zoals altijd. Hij harvestte het nieuwe endpoint, vond geen diff en ging weer slapen. Dat was het moment waarop het project echt klaar was.
De naden, niet de stack
Tijdens deze cutover beet vooral één ding: scope creep vermomd als ‘nu we toch bezig zijn’. Elke verbatim verhuizing leverde ons een dag op; elke verbetering kostte er drie. De uiteindelijke scope was bijna gênant conservatief, en juist daarom hielden die acht weken stand. Staar je naar een eigen Drupal 7- of PHP 7-omgeving, begin dan bij de naden, niet bij de stack — dat is het deel waar wij op leunen wanneer we een legacy-migratie oppakken.
De goedkoopste audit die je vandaag kunt draaien: lijst elke externe URL in je huidige CMS — IIIF-manifesten, image-servers, embed-bronnen, OAI-identifiers — vuur er HEAD-requests op af en groepeer de responscodes. De 404's zijn je echte migratiescope. Het kost een middag en verandert elke schatting.
Kern
Elke verbatim verhuizing leverde een dag op; elke verbetering kostte er drie. Een conservatieve scope hield die cutover van acht weken eerlijk.
FAQ
Waarom niet gewoon Drupal 7 upgraden naar Drupal 10?
Omdat de PHP-omgeving, de contribmodules en de custom code van de uitgever allemaal negen jaar aan schuld droegen. Een side-by-side rebuild op een schone stack was goedkoper dan alleen al de contrib-audit, en ontkoppelde redactie van delivery.
Hoe hielden jullie de OAI-PMH-identifiers stabiel?
We bewaarden de originele Drupal node-ID's als non-nullable kolom in het Strapi-contentmodel en hergebruikten ze in het identifier-schema. De KB heeft nooit een record zien verdwijnen of zien opduiken onder een nieuwe identifier.
Wat gebeurt er met de oude Drupal-site na de cutover?
We bevroren hem read-only, archiveerden een volledige database-snapshot en een wget-mirror, en lieten de container 90 dagen achter basic auth draaien. Daarna is de snapshot het archief. De container is weg.
Zijn er raadpleeg-history-rijen verloren gegaan?
Nul. De verouderde tabel is verbatim naar Postgres verhuisd met het originele schema. Nieuwe writes gebruikten een andere source-tag, zodat de reconciliatie tussen beide systemen tijdens shadow traffic één gegroepeerde telling was.