086 —Operations
SFTP-backups voor verouderde sites: roterende snapshots
Goedkope hosting blokkeert vaak mysqldump en SSH maar laat SFTP openstaan. Een rotatierecept voor snapshots dat je toch een schoon herstelpunt geeft.
Het ticket kwam binnen op een dinsdagmiddag: een Nederlands bureau waar we mee samenwerken had een WooCommerce-shop overgenomen van een klant die zijn vorige developer had ontslagen. De site draaide op een goedkope shared host die in de salespagina opschepte over SFTP-toegang en ondertussen mysqldump, mysql, ssh, cron en alles wat een shell kon raken stilletjes had uitgeschakeld. Het hostingpaneel had een “backup”-knop die een tarball van 14 MB opleverde waar twee van de vier databases in ontbraken. Het bureau had drie dagen tot een productlancering en geen schoon herstelpunt.
Dit is het recept dat we die middag hebben geschreven en sindsdien hebben verfijnd op een handvol legacy site-reddingsoperaties. Het is niet glamoureus. Een roterende snapshot, gebouwd uit SFTP, één PHP-script en een cron op een laptop in Utrecht. Het levert een bruikbare, herstelbare backup op van zowel bestanden als database, en heeft geen toestemming van de hostingmaatschappij nodig om te draaien.
Waarom mysqldump ontbreekt
Drie smaken shared hosting blokkeren mysqldump. De eerste schakelt shelltoegang volledig uit, dus er valt geen binary aan te roepen. De tweede laat SSH staan maar beperkt de MySQL-user tot connecties vanaf localhost, wat remote dumps onmogelijk maakt. De derde, de irritantste, laat alles technisch beschikbaar maar killt elk proces dat langer dan 30 seconden CPU verbruikt, wat precies is wat een dump van een wp_postmeta-tabel van 4 GB doet.
Het resultaat is hetzelfde: je krijgt geen schoon SQL-bestand op de normale manier. Meestal kun je de database wel uitlezen vanuit een PHP-script binnen de document root, want zo werkt de site zelf ook. Dus gebruiken we de site als dumptool en SFTP als transport.
De vorm van de rotatie
Het recept schrijft naar een boom van gedateerde mappen op je lokale schijf (of een NAS, of een goedkope Hetzner-bak, wat je zelf vertrouwt). Het bewaart dagelijkse snapshots voor een week, weekelijkse voor een maand, en maandelijkse oneindig lang. Alles wat ouder is, valt eraf. Je eindigt met zoiets als dit:
backups/example.com/
daily/2026-06-04/
daily/2026-06-05/
daily/2026-06-06/
daily/2026-06-07/
daily/2026-06-08/
daily/2026-06-09/
daily/2026-06-10/
weekly/2026-W21/
weekly/2026-W22/
weekly/2026-W23/
monthly/2026-04/
monthly/2026-05/
monthly/2026-06/
Elke map bevat twee dingen: een files/-mirror van de document root en een db/-map met per tabel één gzipte SQL-file. Splitsen per tabel is belangrijker dan mensen denken. Wanneer de host langlopende PHP-processen killt, sterft een enkele wp_options-dump van 800 MB halverwege en hou je een corrupt archief over. Per-tabel-dumps falen netjes: het script probeert de kapotte tabel opnieuw bij de volgende run, en al het andere staat al op schijf.
Het PHP-dump-endpoint
Upload een klein PHP-bestand in een privémap binnen de document root, afgeschermd met een lang random token en een .htaccess IP-allowlist. Het leest de WordPress- / Drupal- / Magento-config om de databasecredentials te vinden, en schrijft per-tabel SQL-bestanden naar een buurmap die je via SFTP gaat ophalen.
<?php
// _ops/dump.php -- gated by .htaccess + token
if (($_GET['t'] ?? '') !== getenv('DUMP_TOKEN')) { http_response_code(403); exit; }
require __DIR__ . '/../wp-load.php';
@set_time_limit(25); // stay under the host's 30s killer
@ini_set('memory_limit', '256M');
global $wpdb;
$out = __DIR__ . '/snapshots/' . date('Y-m-d');
if (!is_dir($out)) mkdir($out, 0700, true);
$only = $_GET['table'] ?? null; // resume one table at a time
$tables = $only ? [$only] : $wpdb->get_col('SHOW TABLES');
foreach ($tables as $t) {
$file = "$out/$t.sql.gz";
if (file_exists($file)) continue; // already done today
$gz = gzopen($file . '.part', 'wb6');
gzwrite($gz, "DROP TABLE IF EXISTS `$t`;\n");
$create = $wpdb->get_row("SHOW CREATE TABLE `$t`", ARRAY_N);
gzwrite($gz, $create[1] . ";\n");
$offset = 0; $chunk = 500;
while ($rows = $wpdb->get_results("SELECT * FROM `$t` LIMIT $offset,$chunk", ARRAY_A)) {
foreach ($rows as $r) {
$vals = array_map(fn($v) => is_null($v) ? 'NULL' : "'" . esc_sql($v) . "'", $r);
gzwrite($gz, "INSERT INTO `$t` VALUES (" . implode(',', $vals) . ");\n");
}
$offset += $chunk;
}
gzclose($gz);
rename($file . '.part', $file); // atomic finish
}
echo 'ok';
Drie details verdienen hun plek hier. De .part-rename is atomisch op elk POSIX-bestandssysteem, dus een SFTP-poll ziet nooit een half geschreven archief. De zelf-timeout van 25 seconden zorgt dat de killer van 30 seconden van de host niets vindt om te killen. En de file_exists-skip betekent dat je het endpoint in een loop kunt curlen zonder werk dubbel te doen, één tabel per call als de host bijzonder agressief is op CPU-tijd.
De lokale puller
Op de laptop of VPS waar de backups op staan, doet een klein shellscript drie dingen op rij: het dump-endpoint per tabel triggeren, de resulterende map snapshots/YYYY-MM-DD/ via SFTP ophalen, en de document root mirroren. wget doet het eerste, rsync over SSH doet het tweede en derde waar de host het toelaat, en lftp vangt de rest op bij hosts die alleen SFTP spreken.
#!/usr/bin/env bash
set -euo pipefail
SITE=example.com
TODAY=$(date +%F)
DEST="$HOME/backups/$SITE/daily/$TODAY"
mkdir -p "$DEST/files" "$DEST/db"
# 1. ask the site to dump itself, one table per call
for t in $(curl -s "https://$SITE/_ops/tables.php?t=$DUMP_TOKEN"); do
curl -fsS --max-time 30 \
"https://$SITE/_ops/dump.php?t=$DUMP_TOKEN&table=$t" > /dev/null || true
done
# 2. pull the gzipped SQL files
lftp -u "$SFTP_USER,$SFTP_PASS" -e "\
mirror -e /home/$SFTP_USER/public_html/_ops/snapshots/$TODAY $DEST/db; bye\
" sftp://$SITE
# 3. mirror the document root (skip cache + uploads if you already have them)
lftp -u "$SFTP_USER,$SFTP_PASS" -e "\
mirror --only-newer --exclude-glob=wp-content/cache/* \
/home/$SFTP_USER/public_html $DEST/files; bye\
" sftp://$SITE
Draai het vanuit cron elke ochtend om 04:10. Voeg een tweede cron toe op zondag om 04:30 die de daily hardlinkt naar weekly/, en een derde op de eerste van de maand die hardlinkt naar monthly/. Hardlinks zijn de truc die de rotatie goedkoop maakt: een jaar aan maandelijkse snapshots kost je de schijfruimte van één snapshot plus de delta's.
De restore verifiëren
Een ongeverifieerde backup is een gerucht. Elke zondag zou dezelfde machine een wegwerp-MariaDB-container moeten optrekken, daar de db/-map van de vorige nacht in laden, rijen tellen op drie tabellen die ertoe doen (wp_posts, wp_users, wp_woocommerce_order_items) en je een mailtje sturen als één van die tellingen meer dan 5% onder die van vorige week ligt. Het script is twaalf regels bash en heeft het afgelopen jaar twee keer een stille wp_options-truncation gevangen.
Herstellen is de omgekeerde van de dump. zcat db/*.sql.gz | mysql bouwt de database opnieuw op; rsync duwt de files/-mirror terug naar public_html. Test de restore minstens één keer op een staging-subdomein voordat je hem nodig hebt. De eerste keer dat je ontdekt dat in je wp-config.php een hardcoded site-URL stond, is niet de dag dat de site plat ligt.
Waar Pier in past
Dit recept is de bare-metal-versie en hij werkt. Toen we Pier bouwden, liepen we op klantservers steeds tegen hetzelfde patroon op, dus de app heeft nu een “snapshot voor edit”-knop met één klik die de per-tabel-dump en de bestandsmirror maakt zonder dat jij de PHP schrijft, en stempelt hem in de version history naast de wijziging die je ging maken. De MySQL editor leest uit diezelfde snapshot als je een rij wilt diffen tegen vorige dinsdag.
De kleinste nuttige stap voor vandaag is het dump-endpoint. Zet het PHP-bestand in een mapje met token-gate op één site die je belangrijk vindt, curl het één keer met de hand, en kijk wat er terugkomt. De rotatie, de rsync en de verificatie-cron kunnen wachten tot morgen. Het endpoint is het enige stuk dat op de host zelf moet leven.
— Vragen —
Waarom de dump per tabel splitsen in plaats van één groot SQL-bestand?
Shared hosting killt langlopende PHP-processen, meestal na 30 seconden. Eén grote dump sterft halverwege en laat een corrupt archief achter. Per-tabel-dumps falen netjes en hervatten bij de volgende run.
Is het veilig om een dumpscript in de document root te zetten?
Alleen als het achter een lang random token zit, een IP-allowlist in .htaccess, en een mapnaam die een aanvaller niet raadt. Rouleer het token elke keer dat een externe medewerker vertrekt.
En hosts die uitgaand SFTP vanaf mijn machine blokkeren?
Draai de puller op een goedkope VPS in dezelfde regio als de host. Een Hetzner CX11 of Scaleway DEV1-S is genoeg voor een dozijn sites en kost minder dan een koffie per week.