Es gibt eine Frage, die ich in jedem Erstgespräch über ein Altsystem stelle, und sie handelt nicht vom Code: Wie kommt eine Änderung auf den Server?
Die Antworten reichen von „über die Pipeline" bis zu einer Pause, gefolgt von: „Der Kollege lädt das hoch." Wenn die zweite Antwort kommt, ist die Reihenfolge der nächsten Monate entschieden, egal was sonst noch ansteht. Nicht weil FTP altmodisch wäre, sondern weil ohne reproduzierbaren Weg auf den Server jede Modernisierung ein Risiko ist, das niemand beziffern kann.
Dieser Artikel beschreibt den Weg von „jemand lädt hoch" zu „ein Knopf rollt aus, und ein zweiter dreht zurück". Er kommt ohne Container, ohne Cloud und ohne Umbau der Anwendung aus. Das ist Absicht: Der erste Schritt muss klein genug sein, dass er neben dem Tagesgeschäft passt.
Warum die Pipeline vor dem Code kommt
Der Reflex ist verständlich: Der Code ist das Problem, also fängt man beim Code an. Das führt zu einem Zustand, in dem ein Team modernisierten Code über einen unveränderten Weg ausliefert, und der Weg ist die Stelle, an der es schiefgeht.
Drei Dinge fehlen, wenn per FTP ausgeliefert wird, und alle drei sind Voraussetzungen für alles Weitere.
Es gibt keinen Stand. Was auf dem Server liegt, ist die Summe aller Uploads seit Jahren. Ob es dem entspricht, was im Repository steht, weiß niemand. Damit ist jede Aussage der Form „diese Änderung ist drin" eine Vermutung, und jeder Fehlerbericht beginnt mit einer Beweisführung statt mit einer Ursache.
Es gibt keinen Rückweg. Wer hochlädt, überschreibt. Wenn etwas schiefgeht, ist die vorherige Fassung weg, und der Weg zurück führt über ein Backup oder über den Entwickler, der die Datei noch offen hat. Beides dauert.
Es gibt keinen Umschaltmoment. Ein Upload dauert vierzig Sekunden. In diesen vierzig Sekunden läuft die Anwendung halb alt und halb neu, und die Fehler, die dabei entstehen, sind die unangenehmsten überhaupt: Sie treten einmal auf, für einen Nutzer, und lassen sich nie wieder herstellen.
Alles, was dieser Artikel beschreibt, löst genau diese drei Probleme. Schönere Werkzeuge kommen später.
Schritt 1: Was liegt eigentlich auf dem Server?
Bevor irgendetwas automatisiert wird, muss der Unterschied zwischen Repository und Server bekannt sein. Er ist nie null, und er enthält regelmäßig Dinge, die im Repository fehlen und trotzdem gebraucht werden.
# Server-Stand holen, ohne ihn anzufassen
rsync -avn --exclude='.git' --exclude='var/cache' \
benutzer@server:/var/www/anwendung/ ./server-stand/
# Gegen den aktuellen Hauptzweig vergleichen
diff -rq ./server-stand/ ./repository/ | sortDie Ausgabe sortiert sich in drei Gruppen, und jede bekommt eine eigene Antwort.
- Nur auf dem Server, gebraucht. Hochgeladene Dateien, Konfiguration mit Zugangsdaten, ein Zertifikat. Das darf nicht ins Repository, sondern muss aus dem Auslieferungsweg herausgehalten werden. Es wandert in ein Verzeichnis außerhalb des Releases und wird hineinverlinkt.
- Nur auf dem Server, vergessen. Ein Skript von 2019, ein Verzeichnis
alt, eine Datei mit dem Zusatz.backup. Wird nicht gelöscht, sondern beiseitegelegt, und zwar so, dass es in vier Wochen noch auffindbar ist. - Auf beiden, aber verschieden. Das ist die wichtigste Gruppe. Jede solche Datei ist eine Änderung, die jemand direkt auf dem Server gemacht hat und die beim ersten sauberen Deployment verschwindet. Sie gehört ins Repository, bevor die Pipeline steht.
Dieser eine Abgleich ist in meiner Erfahrung der Schritt, der am häufigsten übersprungen wird und am häufigsten den ersten automatischen Rollout zerstört.
Schritt 2: Einmal bauen, mehrfach ausliefern
Der zweite Schritt ist eine Entscheidung, keine Technik: Was auf den Server geht, ist ein Artefakt, und es entsteht genau einmal.
Für eine PHP-Anwendung heißt das: Ein Archiv, das den Code, die installierten Abhängigkeiten und die übersetzten Oberflächendateien enthält. Auf dem Server wird nichts mehr installiert und nichts mehr übersetzt.
composer install --no-dev --optimize-autoloader --no-interaction
npm ci && npm run build
echo "$GIT_SHA" > public/version.txt
tar --exclude='.git' --exclude='node_modules' \
-czf "release-$GIT_SHA.tar.gz" .Die Zeile mit version.txt sieht nach einer Kleinigkeit aus und ist die nützlichste des ganzen Skripts. Ab dann beantwortet ein Aufruf die Frage, welcher Stand läuft, und zwar von außen und ohne Zugang zum Server. Jeder Fehlerbericht beginnt danach mit einer Tatsache.
Warum --no-dev auf dem Bauserver und nicht auf dem Zielserver: Wer auf dem Server installiert, macht das Deployment von der Erreichbarkeit fremder Paketverzeichnisse abhängig. Fällt eines aus, ist nicht nur der Rollout blockiert, sondern auch die Rückrolle.
Schritt 3: Der Umschaltmoment
Jetzt kommt der Teil, der die vierzig Sekunden Halbzustand beseitigt, und er besteht aus einem Verzeichnislayout und einem Symlink.
/var/www/anwendung/
├── releases/
│ ├── 2026-09-16-a3f91c/
│ ├── 2026-09-17-7b2e04/
│ └── 2026-09-18-c81d55/ <- gerade entpackt
├── gemeinsam/
│ ├── uploads/
│ ├── logs/
│ └── .env
└── aktuell -> releases/2026-09-17-7b2e04/Der Webserver zeigt dauerhaft auf aktuell. Ein Deployment entpackt ein neues Verzeichnis unter releases/, verlinkt die gemeinsamen Verzeichnisse hinein, und schwenkt dann den Symlink um. Das Umschalten selbst ist eine einzige atomare Operation:
ln -sfn "/var/www/anwendung/releases/$RELEASE" /var/www/anwendung/aktuell.neu
mv -Tf /var/www/anwendung/aktuell.neu /var/www/anwendung/aktuell
# PHP-FPM merkt sonst nichts vom neuen Pfad: der Opcache haelt die alten
# Dateien anhand ihres aufgeloesten Pfades fest.
sudo systemctl reload php8.3-fpmZwei Fallen stecken in diesen vier Zeilen, und beide sind mir schon zugeschnappt.
Der Umweg über mv -T ist notwendig. Ein direktes ln -sfn auf einen bestehenden Symlink ist nicht atomar; es gibt einen kurzen Moment ohne Ziel. Bei einer Seite mit Last reicht das für ein paar Fehlerseiten.
Der Opcache muss es erfahren. Ohne den Reload liefert PHP weiter aus dem alten Verzeichnis, obwohl der Symlink längst woanders hinzeigt. Das erzeugt genau die Sorte Fehler, die niemand nachvollziehen kann: Die Datei auf der Platte ist neu, das Verhalten ist alt.
Schritt 4: Der Rückweg, und dass er geübt wird
Weil die alten Releases liegen bleiben, ist der Weg zurück derselbe Befehl mit einem anderen Ziel:
VORHER=$(ls -1dt /var/www/anwendung/releases/*/ | sed -n 2p)
ln -sfn "$VORHER" /var/www/anwendung/aktuell.neu
mv -Tf /var/www/anwendung/aktuell.neu /var/www/anwendung/aktuell
sudo systemctl reload php8.3-fpmDer wichtige Teil ist nicht das Skript, sondern der Satz danach: Ein Rückweg, der nie gegangen wurde, ist keiner. Wer ihn zum ersten Mal um 23 Uhr während eines Ausfalls ausprobiert, findet in diesem Moment heraus, dass die Datenbankmigration von vorhin nicht rückwärts läuft.
Deshalb gehört der Rollback in die erste Woche, nicht in die dritte: einmal ausrollen, einmal zurückdrehen, einmal wieder vor. Drei Minuten, und danach ist er eine Tatsache statt einer Absicht.
Das Umschalten zwischen zwei Ständen lässt sich weitertreiben, bis ein Ausrollen ohne Ausfall möglich ist, und dafür braucht es keine Container-Plattform: Blue/Green und Canary ohne Kubernetes.
Schritt 5: Die Datenbank ist die Grenze
Der Symlink dreht Code zurück. Schemaänderungen nicht. Damit ist die Datenbank die Stelle, an der dieses Verfahren aufhört zu funktionieren, wenn man nicht aufpasst.
Die Regel dagegen ist einfacher als ihr Ruf: Jede Schemaänderung ist mit der vorherigen Codefassung verträglich. Praktisch heißt das, dass Änderungen in zwei Schritte zerfallen, die in zwei verschiedenen Auslieferungen stecken. Das Muster heißt Expand and Contract.
Eine Spalte umbenennen sieht damit so aus:
- Auslieferung 1: Neue Spalte anlegen, Code schreibt in beide, liest aus der alten. Der Rückweg ist offen, weil die alte Fassung die neue Spalte schlicht ignoriert.
- Dazwischen: Bestand nachziehen, in Portionen, außerhalb der Auslieferung.
- Auslieferung 2: Code liest aus der neuen Spalte, schreibt weiter in beide.
- Auslieferung 3: Schreiben in die alte Spalte entfällt, die Spalte wird entfernt.
Das sind drei Auslieferungen statt einer, und genau deshalb lohnt sich der Aufwand für die Pipeline: Wenn ein Rollout eine Handlung ist, die niemand anfasst, macht niemand drei davon. Wenn er ein Knopfdruck ist, schon.
Was Sie in der ersten Woche nicht tun
Der häufigste Weg, dieses Vorhaben scheitern zu lassen, ist, es größer zu machen. Vier Dinge bleiben bewusst draußen.
Keine Container. Ein Umzug in Container ist ein eigenes Vorhaben mit eigenen Fragen zu Zustand, Speicher und Protokollen. Er wird leichter, wenn es vorher ein Artefakt und einen Rollout gibt, nicht umgekehrt.
Keine neue Umgebung. Die Pipeline liefert zunächst genau dorthin, wo bisher hochgeladen wurde. Ein zusätzliches Testsystem ist der nächste Schritt und ein guter, aber er verdoppelt die Zahl der Unbekannten.
Keine Testpflicht im ersten Durchlauf. Wenn es keine Tests gibt, hält eine Testschranke die Pipeline nur auf. Sie kommt hinein, sobald es etwas zu prüfen gibt; wie man in einem Altsystem überhaupt an Tests kommt, beginnt bei Characterization Tests.
Kein Umbau der Konfiguration. Die .env bleibt vorerst, wo sie ist, und wird hineinverlinkt. Sie ordentlich zu verwalten ist richtig und kann warten.
Wer den Schritt danach plant, findet den Weg vom Verzeichnis zum Abbild in Ein PHP-Monolith auf ECS Fargate.
Was danach anders ist
Nach ein bis zwei Wochen sieht der Alltag anders aus, und zwar an drei messbaren Stellen.
Die Frage „welcher Stand läuft" hat eine Antwort, und sie steht unter /version.txt. Das klingt klein und beendet eine ganze Klasse von Diskussionen.
Eine Auslieferung kostet keine Verabredung mehr. Vorher war ein Rollout ein Termin, weil jemand konzentriert Dateien schieben musste. Danach ist es ein Knopf, und die Zahl der Auslieferungen pro Woche steigt von selbst. Das ist nicht Selbstzweck: Kleine Auslieferungen sind das wirksamste Mittel gegen große Fehler.
Der Rückweg ist eine Minute. Damit ändert sich, welche Änderungen überhaupt vertretbar sind. Vieles, was vorher „lieber nicht" war, wird zu „probieren wir, wir können zurück".
Genau dieser Punkt war in einem Projekt die Voraussetzung für alles Weitere: eine Redaktionsplattform mit zwanzig Jahren Geschichte ließ sich ohne einen einzigen Ausfalltag umbauen, weil jeder Schritt einzeln ausrollbar und einzeln zurückdrehbar war. Die Pipeline war dort nicht das Ergebnis der Modernisierung, sondern ihre Voraussetzung.
Wenn Sie den Weg nicht selbst gehen wollen: wie ich eine Deployment-Pipeline für ein Bestandssystem baue, steht auf einer eigenen Seite. Der Inhalt oben bleibt derselbe, ob Sie ihn selbst umsetzen oder nicht.
Dieser Artikel gehört zu einer Reihe über Systeme, die es schon gibt. Der Rückblick ordnet alle Artikel der Reihe nach Anlass.

