— Artikel — № 072

072 —Workflow

Staging via alleen SFTP: het lftp-en-rewrite draaiboek

Een werkende staging-omgeving op een SFTP-only host: geen shell, geen git, geen Docker. Alleen lftp, een serialized-safe rewrite en een lock.

Inkttekening mappenboom, rsync-werkblad, manila SFTP-tab, messing STAGING-label, rode lakzegel op .htaccess-uitdraai.
Hero · gestileerd stilleven№ 072

Het is vrijdag 17:40. Een WooCommerce-winkel bij een Nederlandse reseller-host moet naar PHP 8.2 voordat de provider er maandag de knop omzet. De site is twaalf jaar oud, de ontwikkelaar die hem ooit gebouwd heeft is onbereikbaar, en het hostingpakket is de goedkope variant: cPanel, SFTP, MySQL via de publieke socket, geen SSH-shell, geen git, geen Docker, geen staging-slot. De opdracht luidt “zorg dat de checkout blijft werken na de PHP-upgrade.” Zonder een manier om te testen is die opdracht onbeantwoordbaar.

In deze situatie zit een verrassend groot deel van de legacy sites nog steeds. De hostingmarkt is in tweeën gespleten. Managed pakketten geven je push-to-deploy, container-slots en one-click staging. Budgetpakketten geven je SFTP en een phpMyAdmin-tab. De meeste WordPress-, Drupal-, Joomla- en Magento-sites die we onder handen krijgen draaien op een budgetpakket, omdat dat tien jaar geleden is afgesloten en niemand een reden heeft gehad om te wisselen.

Hieronder staat het draaiboek dat we gebruiken als de host alleen SFTP biedt en vrijwel niets anders. De vorm is voor elk CMS hetzelfde: trek productie naar beneden, push een kopie naar een naburige locatie, herschrijf de URLs in code en database, en zet die kopie achter Basic Auth zodat zoekmachines en klanten hem niet kunnen vinden.

De SFTP-only beperking

SFTP is een subsysteem van SSH. Het protocol is hetzelfde. De reden dat een host SFTP kan aanbieden zonder shell-toegang te geven is dat sshd zo te configureren is dat hij file-transfers accepteert en al het andere weigert. Op de meeste goedkope hosts zien de relevante regels in /etc/ssh/sshd_config er ongeveer zo uit:

Match Group sftponly
    ChrootDirectory /home/%u
    ForceCommand internal-sftp
    AllowTcpForwarding no
    X11Forwarding no

Die ForceCommand internal-sftp is de poort. Je kunt inloggen, files listen, uploaden, downloaden. Een command draaien kan niet. Dat betekent geen rsync op de server, geen mysqldump, geen wp-cli, geen tar. Alles moet vanaf je laptop gebeuren, met de remote behandeld als een domme filesystem.

Het goede nieuws is dat het OpenSSH-project het SFTP-subsysteem helder documenteert en het protocol breed ondersteund is. sftp(1) op jouw machine praat met sftp-server(8) op die van hen, en dat is het hele oppervlak waar je mee mag werken.

De mappenstructuur

Voordat er ook maar één bestand verplaatst wordt, bepaal je waar de kopie komt te staan. Twee opties werken:

  • Naburige subdirectory. Plaats de staging-kopie op /public_html/staging/ zodat hij op https://example.com/staging/ draait. Simpelst, goedkoopst, geen DNS-wijzigingen.
  • Subdomein. Maak staging.example.com aan in het DNS-paneel van de host en wijs het aan een naburige map zoals /public_html_staging/. Schonere URLs, makkelijker om cookies te scopen, makkelijker apart af te schermen.

De subdomein-variant heeft de voorkeur als het CMS absolute URLs in de database schrijft, en dat doen WordPress, Magento en Joomla allemaal. Een staging-URL van https://staging.example.com/ is één enkel search-replace-doel. https://example.com/staging/ zijn er twee: de host en het pad-prefix. Dat verschil telt zwaar als je tienduizend rijen moet herschrijven.

Als je alleen een subdirectory kunt krijgen, kies dan een naam die niet te raden is en die niet in de standaard-DNS-template van de host staat. /staging/ is prima zolang je hem ook achter Basic Auth zet. /dev/, /test/ en /preview/ worden binnen een paar uur door bots gevonden.

Productie binnenhalen met lftp

Zonder shell op de server kun je daar geen rsync draaien. Je kunt de server ook niet de boel laten inpakken tot een tarball voor één enkele download. Beide kanten van de SFTP-sessie zijn dom. Productie binnenhalen betekent dus zoveel parallelle SFTP-transfers openen dat de bandbreedte vol zit voordat de protocol-overhead de bottleneck wordt.

De tool die we pakken is lftp. Hij spreekt SFTP, doet directory-mirroring met dezelfde semantiek als rsync, en parallelliseert transfers zonder dat er iets op de server hoeft te draaien. Een pull ziet er zo uit:

lftp -u user,'password' sftp://sftp.example.com -e "
  set sftp:auto-confirm yes;
  set net:connection-limit 8;
  mirror --parallel=8 --only-newer --exclude-glob '*.log' \
         --exclude-glob 'wp-content/cache/*' \
         /public_html/ ~/sites/example.com/prod/;
  bye"

Acht parallelle streams is de sweet spot op de meeste consumentenverbindingen. Meer dan dat en de SSH-daemon van de host begint connecties te weigeren. De --only-newer-vlag maakt van de tweede pull een incremental: alleen bestanden met een nieuwere mtime dan de lokale kopie worden gedownload. Bij een WordPress-installatie van 4 GB met 80.000 mediabestanden duurt de eerste pull een avond. De tweede duurt één koffie.

Heb je lftp niet bij de hand, dan werkt rclone met de sftp-backend vergelijkbaar: rclone sync :sftp:public_html ~/sites/example.com/prod --sftp-host sftp.example.com. Hetzelfde patroon.

De database exporteren zonder shell-toegang

Zonder shell valt mysqldump op de server af. Drie opties blijven over:

  1. phpMyAdmin Export. Werkt voor databases tot ongeveer 200 MB. Daarboven kapt de PHP-execution-timeout de export halverwege af.
  2. Directe MySQL-verbinding vanaf je laptop. Staat de host remote MySQL toe (veel cPanel-hosts doen dat, achter een allowlist), voeg dan je IP toe en draai mysqldump -h db.example.com -u user -p dbname > prod.sql. Dit is de snelste en betrouwbaarste optie.
  3. Een wegwerp-PHP-script. Zet één dump.php in de document root die SHOW TABLES- en SELECT *-output streamt als SQL. Verwijder het script zodra de dump klaar is.

Voor grotere databases werkt de derde route, omdat PHP's fputs naar php://output zo snel streamt als het netwerk toelaat, zonder execution timeout als je ignore_user_abort(true) zet en de rijen in chunks leest. Driehonderd regels code, één IP-allowlist op het script, en direct verwijderen zodra de dump op schijf staat.

URLs herschrijven zonder serialized data te slopen

Hier struikelen de meeste SFTP-only staging-pogingen. De naïeve aanpak is de SQL-dump in een editor openen, https://example.com vervangen door https://staging.example.com en importeren. Op een statische HTML-site werkt dat. Op WordPress, Magento, of wat dan ook dat PHP serialized arrays in de database opslaat, corrupteert het de database.

Serialized strings coderen hun eigen lengte. Een waarde als:

s:18:"https://example.com";

wordt na een domme replace:

s:18:"https://staging.example.com";

De s:18 zegt nog steeds dat de string 18 bytes lang is. De string is nu 26 bytes. PHP's unserialize geeft false terug, en de option, de widget, de theme-setting, het adres-record, wat het ook was, verdwijnt geruisloos uit de admin-UI.

De fix is een tool gebruiken die de database doorloopt, elke waarde unserializet, de replace uitvoert op de strings binnen de structuur, en weer reserializeert. Voor sites waar je wel PHP op de host kunt draaien maar geen shell hebt, is Search Replace DB de standaard. Upload de map via SFTP, open hem in de browser, draai de replace, verwijder de map.

Voor Drupal 9 en 10 is het equivalent het drush-command search_replace, maar drush heeft een shell nodig. Heb je die niet, dan is de route: database exporteren naar je laptop, lokaal een PHP-script met dezelfde logica draaien, en de herschreven dump weer uploaden. Magento 2 bewaart zijn base URL in core_config_data; een gewone SQL-update is daar veilig omdat de URLs niet binnenin serialized arrays genest zitten:

UPDATE core_config_data
SET value = 'https://staging.example.com/'
WHERE path IN ('web/unsecure/base_url', 'web/secure/base_url');

Bij WordPress is de tweede pass die je niet moet vergeten wp_options: rijen als siteurl, home, en elke gecachte transient die de oude URL hardcoded heeft staan. De tool van interconnect/it pakt ze allemaal. De search-replace-docs van WP-CLI leggen het serialized-data-probleem uitgebreider uit en zijn de moeite waard, ook als je WP-CLI niet direct op de host kunt draaien.

De staging-kopie afschermen

Een staging-site die iedereen kan bereiken is geen staging-site. Het is een duplicate-content-penalty die staat te wachten om binnen te komen, een checkout-funnel die echte klanten in verwarring brengt, en op een WooCommerce-site een manier om per ongeluk echte orders aan te nemen. Drie dingen komen in de staging document root voordat ook maar iemand de URL aanklikt.

Basic Auth op de hele subdir. Genereer lokaal een .htpasswd-bestand (htpasswd -c .htpasswd staging) en zet beide bestanden erin:

# .htaccess in /staging/ of de staging-docroot
AuthType Basic
AuthName "Staging"
AuthUserFile /home/example/staging/.htpasswd
Require valid-user

De authentication howto van Apache is de referentie als de host iets anders dan mod_auth_basic draait.

Een robots.txt die alles blokkeert. Voor de zekerheid, mocht Basic Auth ooit tijdens een deploy wegvallen:

User-agent: *
Disallow: /

Een meta noindex op elke pagina. Voor WordPress is de simpelste route een mu-plugin van één regel die <meta name="robots" content="noindex,nofollow"> aan wp_head toevoegt zodra de host header matcht met staging. Voor Magento: zet Stores > Configuration > Design > HTML Head > Default Robots op NOINDEX, NOFOLLOW. De indexing-block-documentatie van Google beschrijft de meta-tag- en header-varianten als je beide nodig hebt.

Eén minder voor de hand liggende valkuil: payment gateways. Stripe, Mollie, Adyen en PayPal whitelisten allemaal de productie-webhook-URL. Je staging-kopie lijkt betalingen aan te nemen en bevestigt ze vervolgens geruisloos niet. Zet de keys op test-modus in het config-bestand van de staging-kopie (wp-config.php, env.php, settings.php) vóór de eerste checkout-test, niet erna.

De refresh-routine

De eerste keer dat je een staging-kopie opbouwt is de dure. Daarna wil je een one-command refresh die productie-deltas binnenhaalt, ze naar de staging-directory pusht, en de database-rewrite opnieuw draait. Gescript kost een refresh een paar minuten:

#!/usr/bin/env bash
set -euo pipefail
SITE=example.com
LOCAL=~/sites/$SITE

# 1. Pull productie
lftp -u "$FTP_USER,$FTP_PASS" sftp://sftp.$SITE \
  -e "mirror --parallel=8 --only-newer /public_html/ $LOCAL/prod/; bye"

# 2. Push naar staging-directory (omgevings-specifieke files behouden)
lftp -u "$FTP_USER,$FTP_PASS" sftp://sftp.$SITE \
  -e "mirror -R --parallel=8 --only-newer \
       --exclude-glob 'wp-config.php' \
       --exclude-glob '.htaccess' \
       $LOCAL/prod/ /public_html_staging/; bye"

# 3. Dump prod DB naar laptop
mysqldump -h db.$SITE -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" > $LOCAL/prod.sql

# 4. URLs in dump herschrijven (handelt WP serialized data af)
php $LOCAL/scripts/serialized-replace.php \
   "https://$SITE" "https://staging.$SITE" \
   < $LOCAL/prod.sql > $LOCAL/staging.sql

# 5. Importeren in staging-DB
mysql -h db.$SITE -u "$DB_USER" -p"$DB_PASS" "${DB_NAME}_staging" < $LOCAL/staging.sql

echo "Staging ververst: https://staging.$SITE"

wp-config.php en .htaccess uitsluiten bij de push is het deel dat mensen vergeten. Beide bestanden zijn omgevings-specifiek. De staging-wp-config.php wijst naar de staging-database. De staging-.htaccess bevat de Basic Auth-regels. Een van beide overschrijven vanuit productie breekt de staging-site geruisloos, meestal halverwege een refresh die je startte omdat je nog vóór het einde van de dag iets wilde testen.

Toen we Pier bouwden liepen we tegen exact dit soort probleem aan bij een van onze beta-klanten, een klein Nederlands bureau dat zes WooCommerce-sites bij een budget-reseller draaide. Wat we uiteindelijk hebben gedaan is de remote SFTP-filesystem en de MySQL-verbinding als één dockbaar doel behandelen, met de version history bovenop beide, zodat een serialized-safe rewrite plaatsvindt vóór er ook maar één bestand of rij de staging-kopie raakt. De MySQL editor kostte het langst om goed te krijgen, omdat hij serialized blobs moest detecteren en weigeren ze met de hand te herschrijven.

Heb je een SFTP-only site waar je al een tijd niet aan durft te beginnen, dan is de kleinste eerste stap: tijd vanavond op je verbinding hoelang een lftp mirror van de docroot duurt. Het getal dat je terugkrijgt is de bodem onder elke andere beslissing in dit draaiboek.

— Vragen —

Kan ik rsync direct over SFTP gebruiken?

Nee. rsync heeft zijn binary aan beide kanten nodig én een shell om hem aan te roepen. Geeft de host je alleen SFTP, gebruik dan het mirror-command van lftp of rclone met de sftp-backend.

Wat breekt er als ik URLs in een SQL-dump met sed search-and-replace?

WordPress en Magento bewaren PHP serialized arrays met URLs erin. Serialized strings coderen hun eigen lengte, dus een naïeve replace maakt ze onparseerbaar en de data verdwijnt geruisloos.

Heb ik een aparte database nodig voor staging?

Ja, of in elk geval een aparte set tabellen in dezelfde database. De productie-database delen met herschreven URLs erin lekt staging-URLs via cron of webhooks terug naar productie.