— Artikel — № 016

016 —Operations

Backups die geen backup zijn: audit in zeven punten

Een agency-lead stuurde om 23:41 een Loom: het restore-bestand was 11 MB, de database 4,7 GB. Zo gebeurt het, en zo voorkom je dat het jou overkomt.

Antieke koperen balansweegschaal op gekrast eiken, één schaal met crème envelop en kleirode lakzegel.
Hero · gestileerd stilleven№ 016

Een Nederlandse agency waar we mee werken stuurde dinsdag om 23:41 een Loom. Drie minuten lang, met een voice-over die na de eerste zin stilviel: "We probeerden net de backup van vorige week te restoren en het SQL-bestand is 11 megabyte." Productie is een Magento 1.9-shop. De database is 4,7 GB. De nachtelijke backup draaide al twee jaar, elke ochtend een groen vinkje, en niemand had het bestand ooit geopend.

Dit is de audit die niemand uitvoert tot het op niets anders meer aankomt. De zeven faalmodes hieronder zien we bij legacy-site opdrachten zo vaak dat we ze inmiddels standaard bij intake aflopen.

Waar de leugen meestal zit

Een backup is twee dingen aan elkaar geniet: een bestand, en een geteste route terug naar draaiende productie. Mist één van de twee, dan heb je hoopvolle opslag. Agencies houden zichzelf voor de gek over backups om dezelfde reden als bij test coverage: groene vinkjes voelen als een contract. Dat zijn ze niet.

1. De hosting-panel snapshot op dezelfde schijf

De "Full Backup"-wizard van cPanel, het on-server archief van Plesk, de dagelijkse tarball van DirectAdmin. Ze schrijven allemaal naar hetzelfde volume waarvandaan de site wordt geserveerd. Faalt de schijf, dan faalt het archief mee. Wordt het account geschorst wegens een openstaande factuur, dan wordt het archief mee geschorst. Eet de RAID-controller van de host zichzelf op, dan is er geen Plan B.

De 3-2-1-regel, beschreven in CISA's data backup guidance, is ouder dan de cloud en gaat nog steeds op: drie kopieën, twee media, één off-site. Een bestand op /home/USER/backups/ is één kopie.

2. De dump die liep maar niet klaar was

mysqldump exit met 0 bij truncatie vaker dan mensen denken. Staat max_allowed_packet op de server op 16M en zit er een geserialiseerde blob van 22M in wp_options, dan stopt de dump te vroeg, ziet het bestand er op het eerste gezicht normaal uit, en sterft de restore later op één enkele rij die niemand kan vinden. De cron stuurt sowieso z'n succesmail.

mysqldump --single-transaction --quick --max-allowed-packet=512M \
  --routines --triggers --events \
  -h db.example.com -u backup_ro -p"$BACKUP_PWD" \
  example_db | gzip > /backups/example_db-$(date +%F).sql.gz

De vlaggen die ertoe doen, allemaal gedocumenteerd op de officiële mysqldump-pagina: --single-transaction om mid-write tearing op InnoDB te voorkomen, --quick om rij voor rij te streamen in plaats van in RAM te bufferen, --max-allowed-packet hoger gezet dan de grootste rij die je ooit zou kunnen hebben, en --routines --triggers --events om de schema-objecten op te pakken die mysqldump anders zonder mededeling weglaat.

Check altijd de staart van het bestand voor je het vertrouwt:

gunzip -c /backups/example_db-2026-05-15.sql.gz | tail -1
# -- Dump completed on 2026-05-15  2:14:38

3. De bestanden die niemand back-upt

De database is sexy. De 38 GB aan wp-content/uploads niet. We zien dit maandelijks: de SQL-backup is in orde, de bestands-backup heeft zeven maanden niet gedraaid omdat iemand een rsync-exclusion schreef om *.zip over te slaan, en daarna iemand anders het dagelijkse archief hernoemde naar backup.zip.

Loop de feitelijke paden op disk af en vergelijk ze met wat er in het off-site archief zit:

ssh prod 'du -sh /var/www/html/wp-content/uploads \
                  /var/www/html/wp-content/plugins \
                  /etc/letsencrypt /etc/nginx'

Liggen die getallen niet binnen een paar procent van wat je laatste archief bevat, dan is de backup die je hebt niet de site die je draait.

4. De off-site die het niet is

Hetzner productieserver, Hetzner Storage Box, beide in FSN1. Ziet er op het diagram uit als off-site. Is het niet. Als Hetzner een regionaal incident heeft, vallen de site en z'n "backup" samen uit. Dezelfde blast radius is geen redundantie.

De goedkope oplossing is een tweede provider voor de cold copy, niet een andere regio van dezelfde. Backblaze B2 naast een Hetzner-productiebak, rsync.net naast AWS, Wasabi naast Vercel object storage. Wat het ook is: het moet als aparte factuur van een apart bedrijf binnenkomen.

5. De restore die nooit is geprobeerd

Een backup die nooit is teruggezet naar een werkende URL is folklore. We hebben agency-contracten met vijf cijfers hierop zien stuklopen. Het script liep, het bestand bestond, niemand heeft het ooit teruggegoten in een verse database en gecheckt of de homepage rendert.

Pak één keer per kwartaal het archief van afgelopen nacht en restore het in een wegwerpcontainer:

docker run -d --name restore-test \
  -e MYSQL_ROOT_PASSWORD=test mysql:8

gunzip -c example_db-2026-05-15.sql.gz | \
  docker exec -i restore-test mysql -uroot -ptest \
  -e "CREATE DATABASE r; USE r; SOURCE /dev/stdin"

Gaat het mis, dan heb je tot het volgende echte incident om uit te zoeken waarom.

6. De secrets die in het archief ontbreken

WordPress salts, Drupal's settings.php, .env-bestanden, Magento's app/etc/env.php met de crypt key. Als die door .gitignore worden uitgesloten én door de backup-rsync, levert het restoren van database en uploads je een half-werkende site op die zijn eigen user data niet kan ontsleutelen.

Bij Magento betekent env.php kwijtraken dat je de crypt key kwijtraakt, en dat opgeslagen payment tokens van klanten onleesbare garbage worden. Bij Drupal maakt het verlies van hash_salt in settings.php elke ingelogde session ongeldig en breekt het one-time login links. Behandel per-site config als een first-class backup-artifact, encrypted at rest, met een gedocumenteerd recovery-pad. Pin het naast de password manager, niet op dezelfde schijf waarvandaan het kwam.

7. De cron die al negen maanden faalt

Cron-jobs die afhangen van uitgaande DNS, een remote mount of een API-key falen stilletjes zodra de omringende infrastructuur de eerste keer verschuift. De job staat nog op de crontab, de mtime op de bestemming is gewoon altijd afgelopen augustus.

find /backups -type f -name '*.sql.gz' \
  -printf '%T+ %p\n' | sort | tail -5

Is het nieuwste bestand in /backups van augustus en is het vandaag mei, dan schrijft je cron al negen maanden naar een verouderd pad of faalt hij zonder melding. Bouw een heartbeat in die succes bewijst, niet alleen exit code 0:

0 3 * * * /usr/local/bin/backup.sh \
  && curl -fsS -m 10 https://hc-ping.com/your-uuid \
  || curl -fsS -m 10 https://hc-ping.com/your-uuid/fail

De afwezigheid van een heartbeat is het signaal dat je echt wil. Wat dan ook dat betrouwbaar naar huis pingt voldoet, het punt is dat stilte een alert wordt.

De audit van vanavond

Twee uur, voor het volgende backup-venster:

  1. SSH naar productie. du -sh op de vier grootste directories op disk.
  2. Open het meest recente off-site archief en verifieer dat dezelfde paden en groottes erin zitten.
  3. Tail de meest recente SQL-dump. Zoek naar -- Dump completed on. Zoek naar elke Got error.
  4. Restore de dump naar een wegwerp-MySQL-container. Open de homepage op /tmp.
  5. Noteer waar het archief staat en op welke factuur het verschijnt. Is dat dezelfde factuur als productie, dan escaleren.
  6. Voeg een heartbeat-URL toe die de cron pingt bij succes, niet alleen op schema.
  7. Zet de volgende restore-oefening in de agenda met een specifieke datum en een benoemde eigenaar.

Toen we Pier bouwden liepen we steeds tegen punt 2, 3 en 5 aan op overgenomen legacy sites, vaak in het eerste uur van een opdracht. Hoe we het in de app uiteindelijk hebben opgelost: een lokale versiegeschiedenis van elke chat-gedreven aanpassing, los gehouden van wat de backup van de host ook doet, zodat het undo-pad nooit afhangt van dezelfde infrastructuur die net stuk ging.

Het kleinste dat je vandaag kan doen: open een terminal, draai het bovenstaande find-commando op je backup-directory, en lees de wijzigingsdatum van je nieuwste bestand hardop voor. Aarzel je bij het jaartal, dan heb je je antwoord.

— Vragen —

Hoe vaak moet een agency restores écht testen?

Minimaal per kwartaal, met een benoemde eigenaar en een agenda-uitnodiging. Niet-geteste backups hebben een gemeten faalpercentage bij de eerste restore dat ruim boven nul ligt, en die kosten landen bij de agency, niet bij de host.

Is een backup uit het hosting-panel ooit op zichzelf genoeg?

Nee. Panel-backups landen vrijwel altijd op dezelfde schijf en hetzelfde account als productie. Behandel ze als een snelle lokale kopie, en zet er een onafhankelijke off-site kopie op de factuur van een andere provider naast.

Wat is de meest voorkomende stille faalmode?

Een mysqldump die afkapt op <code>max_allowed_packet</code> maar toch met 0 exit. Het bestand ziet er normaal uit tot de dag dat je hem probeert te restoren. Verifieer altijd dat de afsluitende 'Dump completed on'-footer aanwezig is.