Die öffentliche Website läuft nicht im eigenen Netz, sondern auf einem bestehenden Shared-Hosting-Paket im Internet. Einen öffentlich erreichbaren Webserver stelle ich mir erst ins Homelab, wenn eine abgetrennte DMZ steht – vorher hat ein Angriffsziel aus dem Internet keinen Platz in einem Netz mit Zugang zu Domäne, Backup oder Management. Gearbeitet wird trotzdem lokal: Alle Artikel, Theme-Anpassungen, Plugins und Updates entstehen in einem internen Staging-Container. Auf dem Live-Server bearbeite ich nichts direkt; ein nächtlicher Dienst überträgt den Stand automatisch nach draußen.

Einbahnstraße vom Staging zum Webhoster
Damit die Trennung zwischen internem Arbeitsstand und öffentlichem Webspace im Alltag hält, gibt es eine feste Regel: Änderungen finden ausschließlich auf dem Staging-System unter https://staging.example.lan statt. Das gilt für neue Texte genauso wie für das Child-Theme, die .htaccess oder Aktualisierungen an WordPress und den Plugins.
Auf dem Live-System sind automatische Updates für den WordPress-Kern und die Plugins abgeschaltet. Wären sie dort aktiv, wäre die produktive Installation irgendwann neuer als das Staging – und der nächste nächtliche Lauf würde das Update mit dem älteren Stand aus dem Container wieder überschreiben. Stattdessen spiele ich Updates im Staging-Container ein (per WP-CLI grundsätzlich als sudo -u www-data wp, damit keine Dateien im Webroot plötzlich root gehören), prüfe die Seite im lokalen Netz und lasse den Stand beim nächsten Lauf auf das Live-System ziehen.
| Eigenschaft | Staging (LXC im LAN) | Live (Shared Hosting) |
|---|---|---|
| Adresse | https://staging.example.lan | https://example.de |
| Umgebungstyp | WP_ENVIRONMENT_TYPE=staging | WP_ENVIRONMENT_TYPE=production |
| Suchmaschinen | ausgeschlossen (blog_public=0) | erlaubt |
| Zertifikat | interne Issuing CA (automatisch erneuert) | Zertifikat des Webhosters |
| Updates & Pflege | hier werden Artikel, Theme und Updates eingespielt | automatische Updates aus, keine direkte Bearbeitung |
| Statistik (Matomo) | eigene Test-Instanz im Container | zentrale Instanz unter eigener Domain (lebt nur live) |
Geplante Beiträge und der Nachtlauf um 03:30 Uhr
Neue Artikel entstehen lokal in einem Git-Repository und werden über ein Skript (artikel-planen.py) mit je einem Erscheinungstag versehen. Als geplante Veröffentlichung wird immer 03:00 Uhr eingetragen. Ein zweites Skript (artikel-auf-staging.py) legt die Beiträge mit diesem Zeitstempel als geplant im Staging-WordPress an. Soll ein bereits eingeplanter Text vor dem Erscheinen noch einmal geändert werden, passe ich ihn im Repository an und spiele ihn erneut auf das Staging; sein geplanter Termin bleibt dabei erhalten.
Auf dem Staging-Container läuft ein Systemd-Timer für den Dienst /usr/local/sbin/staging-veroeffentlichen. Er startet jede Nacht um 03:30 Uhr (Zeitzone Europe/Berlin). Der Dienst prüft zuerst, ob geplante Beiträge inzwischen fällig sind, schaltet sie im Staging frei und stößt – nur wenn es tatsächlich Änderungen gibt – die Übertragung auf das Live-System an.
Neben dem Nachtlauf gibt es zwei weitere Wege, denselben Vorgang auszulösen: von der Kommandozeile über ein Verwaltungs-Skript oder per Knopf auf einer internen Werkzeugseite im Intranet. Damit die Intranet-Maschine dafür keinen offenen Root-Zugang auf dem Staging-Container braucht, ist ihr SSH-Schlüssel in /root/.ssh/authorized_keys an ein Zwangs-Skript gebunden:
command="/usr/local/sbin/staging-fernsteuerung",from="192.0.2.10" ssh-ed25519 AAAA... intranet@management
Das Skript /usr/local/sbin/staging-fernsteuerung lässt ausschließlich die Befehle status und start zu. Den Ausgang jedes Laufs schreibt der Dienst nach /var/lib/wp-staging/veroeffentlichen.json und protokolliert die Details in /var/log/staging-veroeffentlichen.log.
Datenbank sichern und Live-Daten erhalten
Ein einfaches Überschreiben der Live-Datenbank mit einem Abzug aus dem Staging würde alles vernichten, was nur auf dem öffentlichen Server passiert: das eingerichtete Zwei-Faktor-Konto für die Anmeldung und die Kommentare von Lesern (die erst nach Freigabe erscheinen und lokal durch Antispam Bee ohne Drittanbieter gefiltert werden). Deshalb arbeiten auf dem Staging zwei Skripte zusammen: /root/livegang-bauen.sh schnürt das Paket vor, und /root/livegang-veroeffentlichen.sh übernimmt den Abgleich mit dem Webhoster. Die Zugangsdaten für Datenbank und FTP liegen mit Dateirechten 600 unter /root/.config/wp-live.env.

Bevor das Skript irgendetwas auf dem Live-Server verändert, zieht es einen aktuellen Abzug der Live-Datenbank und legt ihn im Container unter /root/livegang-sicherungen ab. Dort werden die letzten zehn Sicherungen aufbewahrt. Erst wenn diese Sicherung vollständig geschrieben ist, läuft der eigentliche Abgleich:
- Live-Datenbank vorab nach
/root/livegang-sicherungensichern (Rotation auf die letzten 10 Abzüge). - Datenbank vom Staging-System auf der Live-Datenbank (MySQL 8.4 beim Webhoster, Import durchgängig mit
utf8mb4) einspielen. - Benutzerkonten, Zwei-Faktor-Einstellungen und Kommentare aus dem Live-Bestand behalten, damit weder der geschützte Login noch Leserkommentare verloren gehen.
- Umgebungsabhängige Werte für das Live-System setzen:
WP_ENVIRONMENT_TYPE=production, Suchmaschinen-Indexierung freigeben und die Adresse der zentralen Matomo-Instanz eintragen. Die SMTP-Zugangsdaten für den Mailversand stehen ohnehin nicht in der Datenbank, sondern alsWPMS_*-Konstanten in derwp-config.php. - Dateien aus
/var/www/htmlper FTP in das Zielverzeichnis beim Webhoster spiegeln und anschließend das Seiten-Cache-Verzeichnis auf dem Live-Server leeren.
Beim Einrichten auf dem Shared Hosting gab es eine Besonderheit bei den Datenbankservern des Anbieters: Während ältere Datenbanken dort noch unter einem Hostnamen mit MySQL 5.7.42 liefen, liegen neu angelegte Datenbanken auf einem anderen Datenbank-Host mit MySQL 8.4 (parallel zu PHP 8.4). Beim automatischen Import muss außerdem explizit der Zeichensatz utf8mb4 mitgegeben werden, damit Umlaute und Sonderzeichen in den Artikeln sauber ankommen.
Die Falle mit dem Cache-Plugin: Absoluter Pfad im Drop-in
Für den Seiten-Cache habe ich auf dem Staging das Plugin Cache Enabler eingerichtet, zusammen mit Sicherheits-Headern und Ablaufzeiten in der .htaccess. Damit bei einem Veröffentlichungslauf keine veralteten HTML-Dateien aus dem Staging hochgeladen werden, klammert die Dateispiegelung das Cache-Verzeichnis aus; stattdessen leert livegang-veroeffentlichen.sh den Cache auf dem Live-Server nach dem Spiegeln direkt per FTP.
Nach dem ersten Lauf waren die neuen Header zwar live, vom Seiten-Cache war im ausgelieferten Quelltext aber nichts zu sehen. Die Seite lief ohne Fehlermeldung weiter, nur eben ungecacht. Die Ursache lag in der Datei wp-content/advanced-cache.php, die Cache Enabler beim Aktivieren und bei jedem Plugin-Update als WordPress-Drop-in anlegt.
Das Plugin schreibt beim Erzeugen dieser Datei keinen dynamischen Pfad, sondern den absoluten Dateisystempfad der Umgebung, in der es aktiviert wurde. Auf dem Staging-Container heißt das Webroot /var/www/html, beim Webhoster liegt die Seite jedoch unter einem Pfad wie /www/BEISPIEL. Wird die unveränderte advanced-cache.php auf das Live-System gespiegelt, sucht das Drop-in beim ersten Seitenaufruf nach der Datei /var/www/html/wp-content/plugins/cache-enabler/constants.php. Weil dieses Verzeichnis auf dem Shared Hosting nicht existiert, greift die eingebaute Schutzlogik des Drop-ins: Es hält die Plugin-Installation für defekt und löscht sich selbst.
Gelöst habe ich das auf dem Staging direkt in wp-content/advanced-cache.php. In Zeile 19 wird der fest kodierte Staging-Pfad durch die WordPress-Konstante WP_CONTENT_DIR ersetzt:
WP_CONTENT_DIR . '/plugins/cache-enabler/constants.php'
Damit stimmt der Pfad sowohl unter /var/www/html/wp-content im Container als auch im Verzeichnis des Webhosters. Weil Cache Enabler die Datei bei einem Plugin-Update neu schreibt, muss Zeile 19 nach jeder Aktualisierung des Plugins kontrolliert und wieder auf WP_CONTENT_DIR umgestellt werden.
Beim anschließenden Prüfen mit curl gab es noch einen zweiten Stolperstein: Ein einfacher Aufruf ohne zusätzliche Header bekommt vom Server wieder keine gecachte Seite. Cache Enabler greift nur bei Anfragen, die explizit HTML anfordern. Für den Test auf der Kommandozeile muss der Header daher mitgegeben werden:
curl -sI -H 'Accept: text/html' https://example.de/
Überwachung und offene Punkte
Ob der nächtliche Lauf funktioniert hat, lese ich nicht aus Logdateien, sondern in Checkmk. Auf dem Staging-Host prüft ein Local-Check die Statusdatei unter /var/lib/wp-staging/veroeffentlichen.json: Meldet das Skript einen Fehler, schlägt der Check sofort auf Kritisch. Dauert ein Lauf länger als eine Stunde oder liegt der letzte erfolgreiche Lauf mehr als 30 Stunden zurück, gibt es eine Warnung. Ein zweiter Check zählt die geplanten Beiträge auf dem Staging und warnt, sobald weniger als fünf Artikel im Vorrat liegen (kritisch unter zwei), ergänzt um eine Frist fünf Tage vor dem letzten geplanten Erscheinungstermin.
Zwei Dinge an diesem Aufbau sind noch unschön bzw. offen. Zum einen existiert das Veröffentlichungs-Skript derzeit doppelt: einmal im Git-Repository unter scripts/livegang-veroeffentlichen.sh und ein zweites Mal als Kopie bei meinen Handskripten, von wo aus das manuelle Start-Skript es auf den Staging-Container kopiert. Beide Dateien müssen bei jeder Änderung synchron gehalten werden. Zum anderen sichert der Nachtlauf zwar vor jedem Abgleich die Live-Datenbank in den Staging-Container (der wiederum täglich im Proxmox Backup Server landet), für die zusätzliche UpdraftPlus-Sicherung direkt auf dem Live-System ist das endgültige Speicherziel – geplant ist die Abholung nach Hause auf den Backup-Speicher – aber noch nicht festgelegt.
Schreibe einen Kommentar