Ausliefern, ohne dass
es jemand merkt.

Ein Deployment, bei dem nichts kaputtgeht, ist noch keins ohne Downtime. Entscheidend ist, was ein Besucher sieht, der genau in den drei Minuten dazwischen eine Seite aufruft.

Die Minuten dazwischen

Das einfachste Deployment einer Laravel-Anwendung besteht aus ein paar Befehlen im Projektverzeichnis auf dem Server: git pull, composer install, npm run build, die Migrationen, die Caches neu bauen. Das funktioniert, und für viele Projekte reicht es lange.

Das Problem sind nicht die Befehle, sondern dass sie im laufenden Verzeichnis arbeiten. Während composer install läuft, liegt in vendor/ ein Gemisch aus alten und neuen Paketen. Nach dem git pull zeigt der Route-Cache noch auf Controller-Methoden, die es vielleicht nicht mehr gibt. Und solange npm run build arbeitet, verweist das ausgelieferte HTML auf Asset-Dateien, die gerade gelöscht und neu geschrieben werden. Wer in diesem Fenster eine Seite aufruft, bekommt einen 500er oder eine Seite ohne Styles.

php artisan down macht daraus ein sauberes Fenster mit Wartungsseite. Das ist ehrlicher, aber es bleibt Downtime. Das Ziel ist ein Deployment, bei dem die alte Version so lange vollständig weiterläuft, bis die neue vollständig fertig ist, und dann in einem einzigen Schritt umgeschaltet wird.

Jede Version in ihrem Verzeichnis

Der Kern des Verfahrens: Jedes Deployment bekommt ein eigenes Verzeichnis. Dort wird die neue Version komplett vorbereitet, während die alte unberührt weiterläuft. Der Webserver kennt keines dieser Verzeichnisse direkt, sondern nur einen Symlink namens current, der auf das aktive Release zeigt.

Umschalten des current-Symlinks auf ein neues Release Die Document Root current/public zeigte bisher auf das Release 20261105172230 und zeigt nach dem Umschalten mit mv -T auf das neue Release 20261106143012. Alle Releases binden .env und storage/ aus dem Verzeichnis shared/ per Symlink ein. Document Root current/public mv -T releases/ älter 20261104091547 bisher aktiv 20261105172230 jetzt aktiv 20261106143012 shared/ .env storage/ verlinkt aus jedem Release
Das neue Release wird vollständig gebaut, während das bisherige weiter ausliefert. Erst dann zeigt current per mv -T auf das neue Verzeichnis. .env und storage/ liegen nur einmal in shared/ und werden aus jedem Release verlinkt.

Als Verzeichnisbaum sieht dasselbe so aus:

Verzeichnisstruktur
/var/www/app/
├── current -> releases/20261106143012
├── releases/
│   ├── 20261104091547/
│   ├── 20261105172230/
│   └── 20261106143012/
└── shared/
    ├── .env
    └── storage/

Die Document Root zeigt auf /var/www/app/current/public. In shared/ liegt alles, was ein Deployment überleben muss: die .env mit den Zugangsdaten und storage/ mit Logs, Sessions, hochgeladenen Dateien und dem Datei-Cache. Jedes Release bindet beides per Symlink ein, statt eine eigene Kopie zu haben. Sonst wären nach dem Umschalten alle Besucher abgemeldet und die Uploads der letzten Woche weg.

Der Ablauf als Skript

Ein Deployment-Tool braucht es dafür nicht. Das folgende Skript enthält alle Schritte und lässt sich so lesen, wie es abläuft. Wichtig ist die Reihenfolge: Alles, was dauert oder scheitern kann, passiert im neuen Verzeichnis, bevor irgendein Besucher davon etwas merkt.

deploy.sh
#!/usr/bin/env bash
set -euo pipefail

APP=/var/www/app
RELEASE="$APP/releases/$(date +%Y%m%d%H%M%S)"

git clone --depth 1 --branch main git@github.com:beispiel/app.git "$RELEASE"
cd "$RELEASE"

# Geteilter Zustand: eine .env und ein storage/ für alle Releases
ln -s "$APP/shared/.env" .env
rm -rf storage
ln -s "$APP/shared/storage" storage

composer install --no-dev --optimize-autoloader --no-interaction
npm ci
npm run build

# Gebaute Assets des alten Releases behalten, damit offene Seiten
# ihre bisherigen Dateien weiter laden können. Gleicher Hash heißt
# gleicher Inhalt, Überschreiben ist also harmlos.
if [ -d "$APP/current/public/build/assets" ]; then
  cp -r "$APP/current/public/build/assets/." public/build/assets/
fi

php artisan storage:link
php artisan migrate --force
php artisan optimize

# Umschalten in einem einzigen Schritt
ln -sfn "$RELEASE" "$APP/current_tmp"
mv -T "$APP/current_tmp" "$APP/current"

php artisan queue:restart

# Die letzten fünf Releases bleiben für einen Rollback liegen.
# Die Zeitstempel im Namen sortieren chronologisch.
ls -1d "$APP"/releases/* | head -n -5 | xargs -r rm -rf

Drei Zeilen darin sind leicht zu übersehen. php artisan optimize baut die Caches für Konfiguration, Events, Routen und Views, und zwar im neuen Verzeichnis. Würde man sie erst nach dem Umschalten bauen, liefe die neue Version die ersten Sekunden ohne Cache oder mit dem alten. Das Kopieren der alten Assets fängt Besucher ab, die die Seite vor dem Deployment geöffnet haben: Ihr HTML verweist auf Dateinamen mit dem alten Hash, und ein nachgeladener Chunk liefe sonst ins Leere. Weil Vite jeden Dateinamen mit einem Hash versieht, kommen sich alte und neue Dateien nicht in die Quere.

queue:restart schließlich ist nötig, weil Queue-Worker langlebige Prozesse sind, die den Code beim Start einmal laden. Ohne Neustart arbeiten sie mit der alten Version weiter. Der Befehl beendet sie nicht hart: Jeder Worker bringt seinen aktuellen Job zu Ende und beendet sich dann. Neu gestartet wird er von einem Process Manager wie Supervisor. Das Signal läuft über den Cache, ein funktionierender Cache-Treiber ist also Voraussetzung.

Atomar umschalten

Warum nicht einfach ln -sfn "$RELEASE" current? Je nach Version der coreutils arbeitet ln dabei in zwei Schritten: Erst wird der alte Symlink gelöscht, dann der neue angelegt. Dazwischen gibt es current für einen kurzen Moment nicht. Bei genug Last trifft genau dieser Moment einen Request.

Der Umweg über current_tmp vermeidet das. Der neue Symlink wird unter einem anderen Namen angelegt, dann benennt mv -T ihn um. Das ist ein einzelner rename()-Systemaufruf, und der ist unter Linux atomar: Jeder Request sieht entweder das alte oder das neue Ziel, nie keines. Die Option -T sorgt dafür, dass mv das Ziel als Datei behandelt und den Symlink nicht in das Verzeichnis verschiebt, auf das current zeigt. Sie gehört zu GNU coreutils, auf jedem üblichen Linux-Server ist sie also vorhanden.

Der Symlink und PHP-FPM

Nach dem Umschalten kann es passieren, dass die Seite trotzdem die alte Version ausliefert, manchmal auch eine Mischung aus beiden. Die Ursache ist PHP-FPM, der FastCGI Process Manager, der die PHP-Prozesse hinter dem Webserver betreibt. PHP merkt sich, wohin ein Pfad aufgelöst wurde. Dieser Realpath-Cache gilt nach der Voreinstellung von realpath_cache_ttl 120 Sekunden lang. Bis er abläuft, führt current/public/index.php für einen Prozess noch ins alte Release. Und OPcache hält den kompilierten Code unter dem aufgelösten Pfad vor.

Die sauberste Lösung ist, PHP gar keinen Symlink-Pfad zu geben. Unter nginx gibt es dafür die Variable $realpath_root, die den Symlink auflöst, bevor der Request an PHP-FPM geht:

nginx/sites-available/app
location ~ \.php$ {
    include fastcgi_params;
    fastcgi_pass unix:/run/php/php8.4-fpm.sock;

    # Pfad mit aufgelöstem Symlink: PHP sieht releases/…, nie current/
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    fastcgi_param DOCUMENT_ROOT $realpath_root;
}

Jeder Request nach dem Umschalten bekommt damit den Pfad im neuen Release, und OPcache legt dafür neue Einträge an. Die alten verdrängen sich mit der Zeit von selbst. Wo du die Webserver-Konfiguration nicht anfassen kannst, etwa bei einem Hosting-Panel, ist ein Reload von PHP-FPM nach dem Umschalten die Alternative, zum Beispiel per systemctl reload php8.4-fpm. Ein Reload lässt laufende Requests zu Ende arbeiten und startet erst dann neue Prozesse. opcache_reset() auf der Kommandozeile hilft dagegen nicht: Die CLI hat ihren eigenen OPcache, der mit dem von PHP-FPM nichts zu tun hat.

Migrationen für zwei Versionen

Die Migrationen laufen im Skript vor dem Umschalten. Für einen Moment arbeitet also die alte Version gegen das neue Schema. Das ist kein Sonderfall, sondern die Regel bei jedem Deployment ohne Wartungsmodus. Daraus folgt eine einfache Bedingung: Jede Migration muss für die alte und die neue Version passen.

Neue Tabellen und neue, nullable Spalten erfüllen das von selbst. Umbenennen und Löschen nicht. Eine umbenannte Spalte fehlt der alten Version sofort, und jeder Request, der sie liest, endet mit einem Fehler. Deshalb wird so etwas auf zwei Deployments verteilt, oft als Expand and Contract bezeichnet:

  • Erstes Deployment: neue Spalte anlegen, der Code schreibt in beide Spalten und liest die neue mit Rückfall auf die alte.
  • Dazwischen: Bestandsdaten per Command oder Job in die neue Spalte übertragen.
  • Zweites Deployment: Code nutzt nur noch die neue Spalte, die Migration löscht die alte.
database/migrations/2026_11_06_000000_add_display_name_to_customers.php
// Erstes Deployment: nur erweitern, nichts entfernen
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('customers', function (Blueprint $table) {
            $table->string('display_name')->nullable()->after('name');
        });
    }
};

Das kostet ein zweites Deployment, macht aber jeden einzelnen Schritt ungefährlich, auch den Rollback im nächsten Abschnitt.

Zurück in einer Sekunde

Weil die vorigen Releases vollständig im Verzeichnis liegen, inklusive vendor/, gebauter Assets und Caches, ist ein Rollback nur ein weiterer Symlink-Wechsel. Kein Build, kein composer install, nichts, was in diesem Moment scheitern könnte:

rollback.sh
#!/usr/bin/env bash
set -euo pipefail

APP=/var/www/app
CURRENT=$(readlink -f "$APP/current")
PREVIOUS=$(ls -1d "$APP"/releases/* | grep -B1 -Fx "$CURRENT" | head -n 1)

if [ "$PREVIOUS" = "$CURRENT" ]; then
  echo "Kein älteres Release vorhanden" >&2
  exit 1
fi

ln -sfn "$PREVIOUS" "$APP/current_tmp"
mv -T "$APP/current_tmp" "$APP/current"

php "$APP/current/artisan" queue:restart

Die Datenbank fasst der Rollback bewusst nicht an. migrate:rollback im Ernstfall ist riskant, weil eine Down-Migration selten getestet wird und Daten löschen kann, die seit dem Deployment geschrieben wurden. Mit Migrationen nach dem Expand-and-Contract-Muster ist das auch nicht nötig: Das alte Release kommt mit dem erweiterten Schema zurecht, es kennt die neue Spalte nur nicht.

Fünf Releases aufzuheben, ist ein guter Mittelwert. Mehr kostet nur Speicherplatz, denn wer weiter als fünf Versionen zurück muss, hat ein Problem, das ein Symlink nicht löst.

Fazit

Ein Deployment ohne Downtime ist weniger Werkzeug als Reihenfolge: vorbereiten, wo niemand zusieht, in einem Schritt umschalten, und den Rückweg offen lassen. Die Release-Verzeichnisse liefern alle drei. Dazu kommen zwei Stellen, die man einmal kennen muss: PHP-FPM, das sich an alte Pfade erinnert, und Migrationen, die für einen Moment zwei Versionen bedienen.

Das ist derselbe Gedanke wie bei einer resilienten Systemarchitektur: Fehler lassen sich nicht ausschließen, aber man kann festlegen, wie weit sie reichen. Ein abgebrochener Build erreicht hier keinen Besucher, und ein fehlerhaftes Release ist eine Sekunde später wieder weg. Deployer und Envoyer setzen das gleiche Verfahren als fertiges Werkzeug um. Wer das Skript einmal selbst geschrieben hat, versteht, was sie tun, und merkt schneller, wenn etwas schiefläuft.

Quellen

Zu php artisan optimize und dem Neustart von Diensten die Laravel-Dokumentation zum Deployment, zum Verhalten von queue:restart der Abschnitt Queue Workers and Deployment, zum Standardwert von realpath_cache_ttl die PHP-Referenz der php.ini-Direktiven.

Fragen zum Thema – oder ein Projekt, das genau daran hängt?

Schreib mir über das Kontaktformular kurz, worum es geht. Ich schaue mir deinen Fall an und sage dir konkret, was sich verbessern lässt – unverbindlich und ohne Verkaufsgespräch.