Doel. Zorg dat WP-Cron niet meer afgaat op bezoekers-page-loads, en draai de WordPress cron-code in plaats daarvan vanuit een echte scheduler. Aan het einde van dit artikel heb je een van drie werkende setups: een cPanel-cronjob die wp-cron.php over HTTPS aanroept, een Linux crontab-regel die WP-CLI direct uitvoert, of een Kubernetes CronJob die wp-cli in een one-shot pod draait. Je weet ook precies hoe je controleert of de nieuwe runner echt op tijd afgaat, wat er na de switch publiek bereikbaar blijft (niet "alles") en welke intervalkeuze er werkelijk toe doet.
Vereisten
- Een WordPress-site. Dit werkt op elke WordPress-install vanaf minstens 5.x. De
DISABLE_WP_CRONconstante zit al jaren in core; de Plugin Handbook over WP-Cron koppelen aan de system task scheduler is de canonieke primaire bron voor de configuratie. - Toegang tot
wp-config.php. Via de bestandsbeheerder van je host, SFTP of SSH. Dit is het enige bestand dat je aan de WordPress-kant aanpast. - Toegang tot een scheduler. Een van: cPanel's "Cron Jobs", een Linux crontab op de host (meestal als webserver-user,
www-dataop Debian/Ubuntu) of een Kubernetes-cluster waar de WordPress-install in draait. - WP-CLI 0.24.0 (juli 2016) of nieuwer, als je het WP-CLI-pad pakt. De 0.24.0-release definieert de
DOING_CRONconstante voordat WordPress geladen wordt voorwp cron event runen voegt de--due-nowflag toe die dit artikel gebruikt, volgens de WP-CLI 0.24.0 release notes. Eerdere versies haddenwp cron event runwel, maar met het DOING_CRON-gat dat plugins breekt die dure code afschermen metif ( defined( 'DOING_CRON' ) ). Iedere WP-CLI van de afgelopen jaren is dus prima. - Een site die op WP-Cron leunt. Heb je al een eigen Action Scheduler-runner of een externe job queue, dan ligt het anders en is dit artikel niet je startpunt.
Dit is de praktische vervangingsgids. Voor het waarom (wat WP-Cron eigenlijk is, de vijf faalmodi en hoe je een kapotte runner diagnosticeert) zie het conceptuele zusterartikel WordPress wp-cron: waarom het misgaat en hoe je het vervangt.
Wat WP-Cron is en waarom het tekortschiet onder load
WP-Cron is een pseudo-scheduler die alleen draait zodra een bezoeker een pagina opent. WordPress start, roept spawn_cron() aan en als er events aan de beurt zijn, vuurt hij een non-blocking HTTP POST af naar wp-cron.php op dezelfde host. Die wp-cron.php-request draait wat aan de beurt is en sluit af. Het mechanisme staat beschreven in de WordPress Plugin Handbook over cron.
Op een drukke site die elke paar seconden een request krijgt werkt dit. Op iedere andere shape van site valt het uit elkaar:
- Sites met weinig verkeer. Een staging-omgeving, een B2B-portaal, een kennisbank voor betalende klanten, een brochuresite. Allemaal kunnen ze uren stilliggen. Ondertussen verschijnen ingeplande posts niet, draaien plugin-update checks niet, slaat transient-cleanup over en op WooCommerce-sites stapelt de Action Scheduler-queue (die standaard op WP-Cron meelift) zich op.
- Sites achter agressieve full-page caching. Een request die uit Varnish, een nginx
fastcgi_cache, LSCache of Cloudflare Cache Reserve komt, raakt PHP nooit. WordPress start niet op. Cron wordt niet aangeroepen. De site kan duizenden gecachete pagina's per uur uitserveren terwijl elke ingeplande taak gewoon zijn moment mist. - Sites waar de loopback HTTP-request geblokkeerd wordt. BasicAuth over de hele site, een security plugin die "server-naar-zichzelf" requests tegenhoudt, Cloudflare bot-rules die origin-IP-verkeer wegfilteren, een TLS-handshake die misgaat binnen het netwerk van de host. De page load gaat gewoon door, de bezoeker ziet niets, en cron loopt stilletjes niet.
- Sites met veel verkeer. Elke
spawn_cron()-aanroep houdt een PHP-worker bezet voor de duur van de loopback POST. PHP-FPM verzacht dit metfastcgi_finish_request(), maar de kosten zijn niet nul, en in een krappe worker-pool kan een langlopende cron-taak ander verkeer in de wachtrij duwen. De performance-kant van dit verhaal staat in PHP-FPM tunen voor WordPress.
Een echte system cron heeft van die vier dingen geen last. Hij draait op zijn eigen klok, in zijn eigen proces, los van het request-pad. Daar gaat het om.
Het wp-cron.php endpoint blijft publiek bereikbaar nadat je WP-Cron uitzet
Dit misverstand kost de meeste tijd, dus zet ik 'm bovenaan.
define( 'DISABLE_WP_CRON', true ); in wp-config.php zetten maakt wp-cron.php niet onbereikbaar. De constante zorgt er alleen voor dat spawn_cron() niet meer afgaat op page loads. Het bestand wp-cron.php staat nog gewoon in je WordPress-root, beantwoordt nog gewoon HTTP-requests en draait nog steeds wat aan de beurt is als je het direct aanroept. De vervanging die je zo gaat toevoegen leunt daar zelfs op: zowel de cPanel-cronjob als het crontab + curl-patroon werken door wp-cron.php over HTTPS aan te roepen.
Twee praktische gevolgen:
- Wilde je voorkomen dat mensen van buitenaf cron-events kunnen triggeren, dan doet WP-Cron uitzetten dat niet. Externe calls blokkeer je op webserver-niveau (
location = /wp-cron.php { allow 127.0.0.1; deny all; }in nginx of een.htaccess-regel in Apache). En als je dat doet, moet de cPanel-cron komen vanuit een request die niet geblokkeerd wordt, meestal een die ontstaat op dezelfde host. - Wilde je
wp-cron.phpweghalen "om cron uit te zetten", doe dat niet. WordPress en veel plugins gaan ervan uit dat het bestand bestaat. Zet de constante en laat het bestand met rust.
De Plugin Handbook bevestigt het ontwerp: WordPress draait WP-Cron standaard op elke page load en als je dat met de constante uitzet, moet je zelf een externe runner inplannen, volgens de system task scheduler-gids.
Stap 1: zet de ingebouwde WP-Cron uit in wp-config.php
Open wp-config.php (via de bestandsbeheerder van je host, SFTP of SSH) en voeg deze regel toe boven de comment /* That's all, stop editing! Happy publishing. */:
// Zet wp-cron uit op elke page load.
// Je MOET hieronder een externe runner toevoegen, anders draait er nooit meer iets.
define( 'DISABLE_WP_CRON', true );
Dat is de enige aanpassing aan WordPress zelf. Geen database-edit, geen plugin om te installeren. De constante wordt gelezen door core's wp_doing_cron() en _wp_cron_lock()-machinerie; zodra hij staat, keert spawn_cron() direct terug op elke page load.
Sla het bestand op. Laad de site een keer en check of hij nog gewoon werkt. Verder verandert er nu zichtbaar niets. Vanaf dit moment tot je een runner toevoegt, stapelen ingeplande events zich op zonder ooit te draaien.
Sla Stap 2 niet over. Een site die met
DISABLE_WP_CRONdraait zonder vervanging is slechter af dan vanilla WP-Cron: ingeplande posts gaan niet meer live, security- en update-checks staan stil, WooCommerce-abonnementen breken in stilte, transient-cleanup gebeurt nooit. Kom je niet verder dan Stap 1, draai het dan terug metdefine( 'DISABLE_WP_CRON', false );(of haal de regel weg) en pak het op zodra je tijd hebt om door te zetten.
Stap 2: voeg een echte system cron toe
Pak het pad dat past bij wat je daadwerkelijk hebt. Eén is genoeg.
Pad A: cPanel (of een hostingpaneel met een cron-UI)
Dit is de fallback voor shared hosting en managed omgevingen waar je geen SSH of WP-CLI krijgt. Open in cPanel Cron Jobs, scroll naar "Add New Cron Job" en vul in:
- Common Settings: kies "Every minute (
* * * * *)" of "Twice per hour (*/30 * * * *)" afhankelijk van je site. De intervalkeuze komt verderop aan bod. - Command:
# cPanel cron-commando, draait elke minuut
curl -sS --max-time 30 -o /dev/null "https://jouwsite.nl/wp-cron.php?doing_wp_cron=1"
Waarom curl en niet wget? Allebei werkt. curl zit vaker standaard op managed hosts en geeft duidelijkere foutmeldingen. --max-time 30 kapt de request af op 30 seconden, zodat een vastlopende cron-run niet steeds nieuwe requests stapelt. -sS houdt curl stil bij succes maar laat foutmeldingen wel door, zodat fouten in de cron-mail terechtkomen. -o /dev/null gooit de response body weg. De ?doing_wp_cron=1 query parameter is een hint voor WordPress; de echte lock-waarde komt uit een microtime van 22 cijfers die core intern aanmaakt.
Blokkeert de loopback van je host het origin-IP-verkeer (Cloudflare bot-rules zijn de meest voorkomende oorzaak), dan moet je support waarschijnlijk vragen om wp-cron.php voor origin-requests te whitelisten. Het symptoom is een 403 of 503 in de cron-mail. Het Advanced Administration Handbook over loopbacks beschrijft wat loopback-requests in een hostingomgeving kapot kan maken.
Verificatie (komt uitgebreider in Stap 3): nadat de eerste geplande minuut voorbij is, draai een wp cron event list --due-now als je WP-CLI hebt, of plan een testpost in voor twee minuten verderop en kijk of hij op tijd verschijnt.
Pad B: Linux crontab + WP-CLI (de betrouwbaarste optie)
Heb je SSH-toegang en WP-CLI geïnstalleerd, dan is dit het pad. WP-CLI start de WordPress-bootstrap direct in een PHP CLI-proces, dus geen HTTP loopback, geen TLS-handshake, geen firewall, geen BasicAuth. De hele klasse van "de loopback is geblokkeerd"-storingen valt weg.
Draai crontab -e als de user die eigenaar is van de WordPress-bestanden (vaak www-data op Debian/Ubuntu, of een aparte site-user op managed hosts). Voeg toe:
# Draai WordPress cron-events elke minuut via WP-CLI.
# --quiet houdt de cron-mailbox leeg tenzij er iets faalt.
* * * * * /usr/local/bin/wp cron event run --due-now --path=/var/www/jouwsite.nl/htdocs --quiet > /dev/null 2>&1
Veld voor veld is dit het standaard Linux crontab(5) vijf-velden-formaat: minuut, uur, dag van de maand, maand, dag van de week. * * * * * betekent "iedere minuut".
Drie redenen waarom WP-CLI hier het juiste gereedschap is:
- Geen loopback. WP-CLI laadt WordPress in-process. Hij doet geen HTTP-request naar zichzelf. Wat de HTTP-route ook brak, valt buiten beeld.
--due-nowdraait alleen wat aan de beurt is. WP-CLI 0.24.0 voegde--due-nowtoe en dewp cron event run-referentie is de officiële documentatie.--due-nowis precies wat je wil voor een runner die elke minuut afgaat: hij loopt over de events waarvannext_runin het verleden ligt, draait ze en sluit af.DOING_CRONwordt correct gezet. Op WP-CLI 0.24.0 en nieuwer wordt deDOING_CRON-constante gezet voordat WordPress wordt geladen voorwp cron event run. Plugins die dure code afschermen metdefined( 'DOING_CRON' )(caching skippen, asset-minify skippen, debug-logging) zien de constante en gedragen zich zoals ze in een echte cron-run zouden doen. Eerdere WP-CLI-versies hadden hier een bug, opgelost in 0.24.0.
Over --quiet: de cron-daemon mailt de output van iedere job naar de lokale user-mailbox. Zonder --quiet krijg je elke minuut een mail met WP-CLI's "Executed the cron event 'X'"-regels. Gecombineerd met > /dev/null 2>&1-redirectie komen alleen catastrofale fouten (zoals een ontbrekende WP-CLI-binary) nog in de mailbox. Precies wat je wil.
Draai je meerdere WordPress-sites op dezelfde host, geef dan iedere site een eigen crontab-regel met een eigen --path=. Loop ze niet in shell langs; je wil per site een zichtbare en los te wijzigen cron-regel.
Pad C: Linux crontab + curl (als WP-CLI niet beschikbaar is)
Sommige VPS-omgevingen en shared SSH-pakketten hebben geen WP-CLI en laten je 'm ook niet installeren. Val dan terug op dezelfde shape als de cPanel-cron, maar dan in crontab:
# Draai WordPress cron via de loopback elke minuut.
* * * * * /usr/bin/curl -sS --max-time 30 -o /dev/null "https://jouwsite.nl/wp-cron.php?doing_wp_cron=1" > /dev/null 2>&1
Functioneel identiek aan Pad A, alleen in crontab-syntax. Pak dit als WP-CLI niet op het pad staat. Pak Pad B als hij er wel is.
Stap 3: verifieer dat de vervanging echt afgaat
Verifiëren is geen optie. Een cronjob die door de scheduler is geaccepteerd is niet hetzelfde als een die succesvol draait. Drie checks, in oplopende volgorde van bewijskracht.
1. De ingeplande-post smoke test. Plan in wp-admin een conceptpost in voor drie minuten verderop. Wacht. Refresh de voorkant na de geplande tijd. Staat de post live, dan draait de runner. Dit is de simpelste end-to-end test en degene die ik altijd als eerste doe.
2. WP-CLI overdue check (elk pad met WP-CLI-toegang, ook over SSH op de host):
# Draai vanuit de site-root, of geef --path= mee
wp cron event list --due-now
Draai 'm één keer, wacht twee minuten, draai 'm opnieuw. Verdwijnen de overdue events, dan draait de runner. Blijven dezelfde events terugkomen, dan draait de runner ze niet en ga je terug naar Stap 2 om uit te zoeken waarom. Voeg --fields=hook,next_run_relative toe voor een leesbaardere weergave.
De volledige command-referentie staat in de WP-CLI cron commands documentatie. Voor de onderliggende WP-Cron-diagnostiek inclusief Site Health en de WP Crontrol-plugin, zie WordPress wp-cron: waarom het misgaat.
3. De cron-mailbox check (alleen Pad B en C). Op Linux mailt de cron-daemon de output van elke job naar de lokale user. Lees ze met mail of less /var/mail/www-data (vervang door de user die de crontab heeft). Met --quiet en > /dev/null 2>&1 blijft de mailbox normaal gesproken leeg. Komen er nieuwe mails binnen, dan draait de cron wel maar faalt het commando. Lees de mail om te zien waarom; veelvoorkomende oorzaken zijn dat WP-CLI niet op $PATH staat (gebruik het volledige pad /usr/local/bin/wp in de crontab, niet wp) of dat --path naar de verkeerde directory wijst.
Voor een WooCommerce-site check je ook Action Scheduler. Ga in wp-admin naar Tools → Scheduled Actions. Sorteer op de "Scheduled"-kolom en kijk naar rijen met datums in het verleden. Komen er nieuwe rijen door naar "Completed" nadat je runner live ging, dan loopt Action Scheduler bij via het WP-Cron-pad dat je net vervangen hebt.
Speciaal geval: WordPress in Docker of Kubernetes (WP-CLI als een CronJob)
Draait WordPress in een container, dan is het productie-patroon hetzelfde idee in een andere vorm: draai wp cron event run --due-now op een schedule, maar als losse one-shot pod, niet als sidecar in de WordPress-pod.
Dat maakt verschil omdat een sidecar continu draait en de cron-daemon erin nu ook weer een proces is dat je gezond moet houden, met eigen logging en eigen faalmodi. Een Kubernetes CronJob-resource past beter: de cluster-controller vuurt de schedule, draait de pod tot voltooiing, ruimt het resultaat op en surfaced fouten via standaard pod-status-API's.
Een minimale CronJob voor een WordPress-install waarvan de WP-CLI image toegang heeft tot hetzelfde persistent volume als de WordPress-pods ziet er zo uit:
# k8s/wp-cron.yaml: elke minuut, draai WordPress cron-events die aan de beurt zijn
apiVersion: batch/v1
kind: CronJob
metadata:
name: wp-cron
namespace: wordpress
spec:
schedule: "* * * * *"
# Forbid concurrent runs: sla een tick over in plaats van pods stapelen als een run traag is.
concurrencyPolicy: Forbid
# Strakke deadline: een misgeconfigureerde CronJob faalt snel in plaats van een uur te retryen.
startingDeadlineSeconds: 60
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: wp-cli
image: wordpress:cli # Alpine-based WP-CLI image; pin een digest in productie
args:
- wp
- cron
- event
- run
- --due-now
- --path=/var/www/html
# UID 33 matcht Debian www-data, wat de wordpress:php-apache image gebruikt.
# Alpine www-data is UID 82; zonder dit schrijft de CronJob bestanden waar de
# WordPress-pods niet bij kunnen.
securityContext:
runAsUser: 33
runAsGroup: 33
volumeMounts:
- name: wp-content
mountPath: /var/www/html
env:
- name: WORDPRESS_DB_HOST
value: mariadb
- name: WORDPRESS_DB_USER
valueFrom:
secretKeyRef:
name: wordpress-db
key: username
- name: WORDPRESS_DB_PASSWORD
valueFrom:
secretKeyRef:
name: wordpress-db
key: password
- name: WORDPRESS_DB_NAME
value: wordpress
volumes:
- name: wp-content
persistentVolumeClaim:
claimName: wp-content
concurrencyPolicy: Forbid is hier de belangrijke regel. De Kubernetes CronJob-documentatie noemt drie opties: Allow (standaard, stapelt pods als een run traag is), Forbid (sla de volgende tick over als de vorige nog draait) en Replace (kill de draaiende pod en start een nieuwe). Voor een schedule van één minuut is Forbid de juiste default. Duurt een cron-run langer dan 60 seconden, dan wil je dat de volgende minuut overgeslagen wordt, niet dat er een tweede run start die met de eerste vecht.
De Alpine vs Debian UID-valkuil is dezelfde die ik beschrijf in het WordPress Docker setup-artikel. De wordpress:cli image is Alpine, met www-data als UID 82. De wordpress:php-apache image is Debian, met www-data als UID 33. Schrijft de CronJob bestanden weg als UID 82 en serveren de WordPress-pods ze als UID 33, dan krijg je permissiefouten die willekeurig lijken omdat ze alleen opduiken bij bestanden die cron echt aanmaakt (plugin-updates, transient-bestanden, gegenereerde assets). Eén keer fixen met runAsUser: 33 in de security context.
Hetzelfde persistent volume moet zowel in de WordPress-deployment als in de CronJob gemount worden, met accessModes die ReadWriteMany toestaan als de deployment over één replica heen schaalt. Op een single-replica install is ReadWriteOnce prima.
Apply met kubectl apply -f k8s/wp-cron.yaml en verifieer met:
kubectl -n wordpress get cronjob wp-cron
kubectl -n wordpress get jobs --watch
kubectl -n wordpress logs -l job-name=wp-cron-<pod-id>
Het eerste commando toont schedule en LAST SCHEDULE. Het tweede laat pods aanmaken en afronden zien. Het derde toont de WP-CLI-output van een specifieke run. Loopt LAST SCHEDULE wel door maar verschijnen er geen jobs, check dan concurrencyPolicy en de logs van de cluster-CronJob-controller.
Troubleshooting: taken draaien nog steeds niet na de switch
Cron-mail elke minuut met wp: command not found (Pad B). De wp-binary staat niet op het PATH van de cron-daemon. Fix dit door het volledige pad in de crontab-regel te zetten, niet wp. Draai which wp vanuit een normale shell om het pad te vinden (vaak /usr/local/bin/wp) en zet dat in de crontab.
Cron-mail met Error: This does not seem to be a WordPress installation. Pass --path=<path> ... (Pad B). De crontab draait vanuit de verkeerde directory. Voeg --path=/volledig/pad/naar/wordpress toe aan de WP-CLI-aanroep (aanbevolen) of cd in de cron-regel zelf naar de WordPress-root. Het eerste is explicieter en minder fragiel.
curl-cronjob geeft HTTP 403 of 503 (Pad A of C). De loopback van je host blokkeert origin-IP-verkeer. Dit is de meest voorkomende oorzaak op hosts achter Cloudflare. Vraag de host om wp-cron.php te whitelisten voor origin-requests, of stap over op Pad B (WP-CLI) dat het HTTP-pad helemaal omzeilt. Het Advanced Administration Handbook over loopbacks is de primaire bron voor wat loopback-HTTP in een hostingomgeving blokkeert.
Ingeplande posts publiceren wel maar Action Scheduler stapelt zich nog op (WooCommerce-sites). Action Scheduler draait een eigen queue bovenop het WP-Cron-pad. Draait de runner wel maar loopt Action Scheduler niet bij, check dan of action_scheduler_run_queue in de cron event list staat (wp cron event list | grep action_scheduler). Staat hij erin en draait hij toch niet, dan zit het probleem in Action Scheduler, niet in de runner. Verhoog --max-time als je curl-cron timeout krijgt voordat Action Scheduler een batch heeft afgerond.
Cron draait wel maar events blijven overdue. Een specifiek event faalt in zijn hook-callback. Draai 'm handmatig met wp cron event run <hook-naam> om de error direct te zien. Veelvoorkomende oorzaken: een externe API die het event aanroept ligt eruit, een plugin die het event registreerde is gedeactiveerd terwijl het event nog bestaat, of de hook-callback gooit een fatal die de cron-mail niet doorzet. Voor het bredere patroon van vastzittende events, zie WordPress wp-cron: waarom het misgaat.
Kubernetes CronJob: LAST SCHEDULE loopt door maar er bestaan geen pods. Check concurrencyPolicy en de logs van de cluster-CronJob-controller (kubectl -n kube-system logs -l app=kube-controller-manager). De meest voorkomende oorzaak is een te kleine startingDeadlineSeconds voor een drukke cluster: kan de controller de pod niet binnen de deadline starten, dan slaat hij de run over.
Drie misvattingen die het corrigeren waard zijn
"WP-Cron uitzetten betekent dat ingeplande posts niet meer publiceren." Alleen als je geen echte runner toevoegt. De ingeplande events staan in de cron-optie in wp_options. WP-Cron uitzetten haalt alleen de trigger weg die ze draait. Zodra een system cron dezelfde WordPress-codebase oppakt, draaien die events weer op tijd, ingeplande posts incluis.
"wp-cron.php wordt onbereikbaar zodra WP-Cron is uitgezet." Niet waar. wp-cron.php blijft publiek bereikbaar over HTTPS en draait gewoon wat aan de beurt is als je 'm direct aanroept. De DISABLE_WP_CRON-constante zet alleen de in-WordPress trigger uit die vanuit spawn_cron() op page loads afgaat. Wil je externe toegang écht blokkeren, dan is dat een webserver-regel, niet een WordPress-regel.
"Je hebt een interval van één minuut nodig." Niet per se. De trade-off is reëel: een interval van één minuut respecteert het kortste geregistreerde event-interval (de defaults van WordPress core beginnen bij hourly, maar plugins kunnen schedules van vijf of één minuut registreren via de cron_schedules-filter, en Action Scheduler draait zijn queue standaard elke minuut). Een vijf-minuten-interval is prima op een site zonder events korter dan vijf minuten en geeft minder logruis. Een interval van 15 minuten is te lang voor elke niet-triviale site: ingeplande posts kunnen 15 minuten te laat verschijnen, password-reset mail-queues lopen op en Action Scheduler voelt niet meer responsief. Pak één minuut bij sites met WooCommerce of een nieuwsbrief- of notificatieplugin; pak vijf minuten bij rustige content-sites; pak niets langer zonder specifieke reden.
Wil je daarna ook het request-pad zelf in productie anders laten gedragen (full-page caching, FPM-tuning, het bredere "site is snel voor bezoekers maar cron blijft betrouwbaar"-vraagstuk), dan zijn de logische volgende stappen PHP-FPM tunen voor WordPress en het wp-cron concept-artikel. Wil je dit eerst valideren in een staging-omgeving voordat je het op productie loslaat, zie dan WordPress staging-omgeving.