Warum diese Architektur überhaupt nach atomaren Deployments verlangt
Der Stack ist bewusst entkoppelt. Vorne eine Angular-SPA, hinten eine zustandslose Laravel-API, die den Datenzugriff kontrolliert. Strikte Trennung von Frontend und Backend. Genau diese Trennung macht das Deployment interessant, weil Sie zwei unterschiedliche Artefakte haben: ein statisches JavaScript-Bundle und eine PHP-Anwendung mit Datenbank-Migrationen.
Das Angular-Bundle ist nach dem Build unveränderlich. Es ist eine Sammlung von HTML, JS und CSS, die irgendein Webserver oder CDN ausliefert. Die Laravel-Seite dagegen hat Zustand am Rand — Migrationen, Caches, Queues, einen Storage-Symlink. Beim Deploy darf nie der Moment entstehen, in dem das neue Frontend schon lädt, die API aber noch auf dem alten Schema sitzt. Oder umgekehrt.
Aus meiner Projekterfahrung ist das der Grund, warum „einfach per git pull auf dem Server" bei geschäftskritischen Enterprise-SPAs scheitert. Während composer install läuft, ist die App in einem undefinierten Zwischenstand. Atomare Releases lösen genau das: Es gibt immer nur zwei Zustände — altes Release live oder neues Release live. Nichts dazwischen.
Wenn Sie tiefer in die Architekturentscheidungen dahinter einsteigen wollen, habe ich das im Angular + Laravel Leitfaden und konkret in Angular + Laravel Architektur ausgeführt.
GitHub Actions als Gate: erst prüfen, dann bauen
Die Pipeline hat eine klare Reihenfolge. Erst die Qualitätssicherung — automatisierte Tests und Linting für beide Seiten. Erst wenn das grün ist, lohnt sich der Build. Ein Frontend-Bundle, das eslint-Fehler hat oder dessen Backend-Tests rot sind, hat auf dem Server nichts verloren.
Eine knappe Skizze für den Workflow:
name: deploy
on:
push:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
# --- Laravel: Tests + Linting ---
with: { php-version: '8.3' }
# --- Angular: Lint + Build ---
with: { node-version: '20', cache: 'npm' }
# Bundle als Artefakt fuer den Deploy-Job
with:
name: angular-dist
path: dist/
Wichtig ist die Trennung der Schritte. PHP-Setup, composer install, Pint, dann die Tests. Danach Node, npm ci, Lint, Production-Build. Den Production-Build des Angular-Frontends bauen Sie genau einmal — in der Pipeline, nicht auf dem Zielserver. Der Server soll kein node_modules und keinen Build-Schritt kennen müssen.
Drei Dinge, die ich in jeder Pipeline für diesen Stack verdrahte:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
- run: composer install --no-interaction --prefer-dist
- run: ./vendor/bin/pint --test # Code-Style
- run: php artisan test # PHPUnit / Pest
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run lint
- run: npm run build -- --configuration production
- uses: actions/upload-artifact@v4
- Linting als hartes Gate, nicht als Warnung —
pint --testundnpm run lintbrechen den Lauf ab. - Den Angular-Build mit
--configuration production, damit Optimierung und korrekte Environment-Files greifen. - Das fertige
dist/-Verzeichnis als Artefakt, damit der Deploy-Job es nicht neu bauen muss.
Deployer: atomare Releases und der Symlink-Swap
Deployer ist das Werkzeug, das die Zero-Downtime-Auslieferung übernimmt. Das Prinzip: Jede neue Version landet in einem eigenen Release-Verzeichnis, nummeriert oder per Zeitstempel. Geteilte Dinge wie .env und storage liegen außerhalb in einem shared-Ordner und werden in jedes Release hineingelinkt. Ist das Release fertig vorbereitet, zeigt ein zentraler current-Symlink darauf — und zwar in einem einzigen, atomaren Schritt.
So sieht die Verzeichnisstruktur auf dem Server aus:
/var/www/app
├── current -> releases/20260628153000 # der Live-Symlink
├── releases/
│ ├── 20260628153000/ # neues Release
│ └── 20260628120000/ # voriges Release (fuer Rollback)
└── shared/
├── .env
└── storage/
Ein Auszug aus deploy.php, der die Laravel- und Angular-Spezifika abbildet:
<?php
namespace Deployer;
require 'recipe/laravel.php';
set('application', 'angular-laravel-app');
set('repository', 'git@github.com:acme/app.git');
// Bleiben ueber Releases hinweg erhalten
set('shared_files', ['.env']);
set('shared_dirs', ['storage']);
// Brauchen Schreibrechte fuer den Webserver
set('writable_dirs', ['bootstrap/cache', 'storage']);
host('production')
->set('hostname', 'app.example.com')
->set('deploy_path', '/var/www/app');
// Migrationen kontrolliert fahren, BEVOR der Symlink swappt
task('artisan:migrate', function () {
run('{{bin/php}} {{release_path}}/artisan migrate --force');
});
// Caches im neuen Release frisch aufbauen
task('laravel:optimize', function () {
run('{{bin/php}} {{release_path}}/artisan config:cache');
run('{{bin/php}} {{release_path}}/artisan route:cache');
run('{{bin/php}} {{release_path}}/artisan view:cache');
});
after('deploy:vendors', 'artisan:migrate');
after('artisan:migrate', 'laravel:optimize');
// Bei Fehler aufraeumen
after('deploy:failed', 'deploy:unlock');
Der entscheidende Moment ist deploy:symlink, den die Laravel-Recipe intern setzt. Bis dahin läuft alles im neuen Release-Ordner, während die Live-Anwendung unter dem alten current weiterläuft. Der Swap selbst ist ein Symlink-Umhängen — auf Dateisystemebene atomar. Es gibt keinen Bruchteil einer Sekunde, in dem der Pfad ins Leere zeigt.
Und der Rollback? Genau dieselbe Mechanik rückwärts. dep rollback setzt current zurück auf das vorige Release-Verzeichnis. Solange das alte Release noch da ist, dauert das Sekunden. Das ist der eigentliche Sicherheitsgurt: Sie deployen mutiger, weil das Zurück so billig ist.
Build-Strategie: Angular-Bundle ausliefern, Laravel migrieren
Hier trennen sich die zwei Welten sauber. Für das Frontend gilt: einmal bauen, statisch ausliefern. Sie haben grundsätzlich zwei Wege.
Entweder Sie kopieren das Angular-dist/ in den Public-Bereich der Laravel-Anwendung und lassen Laravel die index.html als Fallback-Route ausliefern. Oder — und das ist bei strikter Trennung der sauberere Weg — Sie hosten das Bundle separat, etwa auf einem CDN oder einem eigenen statischen Host, und Laravel bleibt reine API. In beiden Fällen ist das Bundle nach dem GitHub-Actions-Build fertig und unveränderlich.
Die API-Schicht bleibt zustandslos. Sie liefert über Laravel API Resources kontrolliert JSON aus — kein 1:1-Leak der Eloquent-Modelle. Das ist für Deployments relevant, weil ein gut versioniertes API-Resource-Layer es erlaubt, das Frontend und Backend in kleinen, kompatiblen Schritten weiterzuentwickeln. Mehr dazu in Laravel API Resources und Angular-TypeScript.
Für die Migrationen mein klarer Rat: vor dem Symlink-Swap fahren, und additiv halten. Eine Migration, die eine Spalte hinzufügt, ist mit altem und neuem Code kompatibel. Eine, die eine Spalte umbenennt oder löscht, ist es nicht — die bricht in dem kurzen Fenster, in dem altes Frontend gegen neues Schema läuft. Bei heiklen Schema-Änderungen arbeite ich zweistufig: erst Spalte hinzufügen und deployen, dann im nächsten Release die alte entfernen.
So sortiere ich die Build-Verantwortung:
- Angular: kompilieren in der Pipeline, das fertige Bundle als unveränderliches Artefakt ausliefern.
- Laravel: Code ins Release,
composer install --no-dev, dannmigrate --forcevor dem Swap. - Schema-Änderungen additiv halten; destruktive Änderungen über zwei Releases verteilen.
Die vier Stolperfallen, die in der Praxis wirklich wehtun
Soviel zur Theorie. In echten Projekten kostet nicht der Symlink-Swap die Nerven, sondern vier konkrete Details.
**.env und Environments.** Die .env-Datei darf niemals im Repository liegen und niemals pro Release neu geschrieben werden. Sie gehört in den shared-Ordner und wird in jedes Release gelinkt — siehe shared_files oben. Auf Angular-Seite hat das ein Gegenstück: Die Environment-Konfiguration wird zur Build-Zeit eingebacken, nicht zur Laufzeit. Wenn die API-URL für Produktion stimmen soll, muss sie beim npm run build -- --configuration production in der Pipeline gesetzt sein. Das ist eine häufige Verwechslung. Frontend-Environment ist Build-Zeit, Backend-Environment ist Laufzeit.
Config-Cache. php artisan config:cache ist großartig für die Performance und eine Falle zugleich. Sobald der Config-Cache existiert, liest Laravel env()-Aufrufe außerhalb der Config-Dateien nicht mehr verlässlich. Cachen Sie die Config also immer im neuen Release nach der Migration, nie vorher, und stellen Sie sicher, dass alle env()-Zugriffe über config() laufen. Bei einem Rollback auf ein altes Release mit altem Config-Cache muss der gegebenenfalls neu gebaut werden.
Storage-Symlink. php artisan storage:link erstellt den Symlink von public/storage nach storage/app/public. Bei atomaren Releases ist das tückisch, weil public in jedem Release neu liegt, storage aber geteilt ist. Deployer kümmert sich über shared_dirs um storage selbst; der storage:link muss aber pro Release im richtigen Release-Pfad gesetzt werden. Sonst zeigen Ihre hochgeladenen Dateien nach dem ersten Deploy plötzlich ins Leere.
Queue-Restart. Das ist die Falle, die am leisesten zuschlägt. Laravel-Queue-Worker sind langlebige PHP-Prozesse. Sie laden den Code einmal beim Start und halten ihn im Speicher. Nach einem Deploy laufen Ihre Worker also noch mit dem Code des alten Release — selbst wenn der Symlink längst auf das neue zeigt. Die Folge sind subtile, schwer reproduzierbare Bugs, bei denen Jobs gegen veralteten Code laufen. Die Lösung ist ein php artisan queue:restart am Ende jedes Deploys, das die Worker nach dem aktuellen Job sanft neu startet.
Als Deployer-Task sieht das so aus:
task('artisan:queue:restart', function () {
run('{{bin/php}} {{release_path}}/artisan queue:restart');
});
// Nach dem erfolgreichen Symlink-Swap
after('deploy:symlink', 'artisan:queue:restart');
Die vier Punkte als Checkliste vor dem ersten Produktiv-Deploy:
.envinshared, Angular-Environment zur Build-Zeit gesetzt.config:cacheerst im neuen Release nach der Migration, alleenv()überconfig().storage:linkpro Release korrekt gesetzt,storagealsshared_dir.queue:restartnach jedem Swap, sonst laufen Worker auf altem Code.
Mein Fazit nach mehreren Stack-Migrationen
Das Schöne an dieser Kombination ist die klare Arbeitsteilung. GitHub Actions ist Ihr Qualitäts-Gate und Ihre Build-Fabrik, Deployer Ihr Auslieferungs-Mechanismus. Keiner der beiden weiß zu viel über den anderen, und genau das macht das Setup wartbar.
Wenn ich ein Projekt frisch aufsetze, baue ich zuerst Deployer mit dem manuellen dep deploy zum Laufen, prüfe den Symlink-Swap und teste einmal bewusst einen Rollback. Erst danach hänge ich GitHub Actions davor. So weiß ich, dass die schwierige Hälfte funktioniert, bevor die Automatisierung sie verdeckt. Die Reihenfolge zahlt sich aus.
FAQ
Brauche ich Deployer, oder reicht ein GitHub-Actions-Deploy per SSH und rsync?
Technisch geht rsync, aber Sie verlieren die atomare Auslieferung und den billigen Rollback. Deployer gibt Ihnen Release-Verzeichnisse, den Symlink-Swap und dep rollback quasi geschenkt. Bei geschäftskritischen Anwendungen ist mir das die Abhängigkeit wert. Für ein reines Hobbyprojekt wäre rsync vertretbar.
Wo läuft der Angular-Build — auf dem Server oder in der Pipeline?
In der Pipeline. Der Server soll kein Node und kein npm install brauchen. GitHub Actions baut das dist/-Bundle einmal mit --configuration production, und nur dieses fertige Bundle wird ausgeliefert. Das hält den Server schlank und macht Builds reproduzierbar.
Wie verhindere ich, dass Migrationen die Anwendung während des Deploys brechen?
Migrationen vor dem Symlink-Swap fahren und additiv halten. Spalten hinzufügen ist sicher, weil alter und neuer Code damit klarkommen. Umbenennen oder Löschen verteilen Sie auf zwei Releases: erst hinzufügen und deployen, dann im nächsten Schritt die alte Spalte entfernen.
Mein Frontend zeigt nach dem Deploy die alte API-URL — warum?
Weil die Angular-Environment-Konfiguration zur Build-Zeit eingebacken wird, nicht zur Laufzeit. Wenn die Produktions-URL nicht stimmt, war sie beim npm run build in der Pipeline falsch gesetzt. Anders als Laravels .env, die zur Laufzeit gelesen wird, müssen Sie beim Frontend neu bauen, um eine geänderte Environment-Variable zu übernehmen.
Warum laufen meine Queue-Jobs nach dem Deploy mit altem Code?
Queue-Worker sind langlebige Prozesse, die den Code beim Start in den Speicher laden. Ohne php artisan queue:restart nach dem Swap laufen sie weiter auf dem alten Release. Hängen Sie den Restart als Deployer-Task nach deploy:symlink — dann beenden die Worker den aktuellen Job sauber und starten mit dem neuen Code neu.
Wie schnell ist ein Rollback wirklich?
Sekunden, solange das vorige Release-Verzeichnis noch existiert. dep rollback setzt den current-Symlink zurück — derselbe atomare Vorgang wie der Swap, nur rückwärts. Achten Sie nur darauf, dass ein Config-Cache aus dem alten Release noch passt; im Zweifel im rollback-Pfad neu cachen.
---
Sie planen ein Angular-plus-Laravel-Projekt und wollen ein Deployment, bei dem niemand das Wartungsfenster spürt? Ich helfe beim Aufsetzen der Pipeline und der atomaren Auslieferung — von der ersten deploy.php bis zum getesteten Rollback. Schreiben Sie mir über die Kontaktseite, dann schauen wir uns Ihren Stack konkret an.
