# 01 — Git-Deploy per Tag Ausgeliefert wird ein **Git-Tag**, nie ein Branch-Stand. Das Live-Verzeichnis ist ein Git-Checkout dieses Tags. Ein Skript (`vorlagen/deploy.sh`) führt den Deploy von Hand per SSH aus und bricht ab, **bevor** es etwas verändert, wenn etwas nicht stimmt. Erprobt mit: Ploi-verwaltetem Ubuntu-Server, PHP-FPM 8.4, Nginx, Supervisor, MySQL, Forgejo als Git-Server. Läuft genauso mit Forge, plain VPS, GitHub/GitLab. --- ## Grundsätze - **Nur Tags** (`vX.Y.Z`, SemVer): Feature-Release erhöht die mittlere Stelle, Hotfix die letzte. Die Tag-Nachricht ist die Release-Notiz (Inhalt, Migrationen, neue `.env`-Werte, genauer Deploy-Befehl). - **Deploy nur von Hand** per SSH. Push-Webhooks und „Quick Deploy“ im Server-Panel bleiben aus — ein Push ist noch kein Release. - **Assets lokal bauen und committen** (`public/build` versioniert). Auf dem Server gibt es kein Node. Vite benennt Dateien nach Inhalt — ohne Frontend-Änderung kein Diff. - **Auf dem Server wird nichts von Hand geändert.** Das Skript bricht ab, sobald der Checkout vom Tag abweicht. Auch Hotfixes laufen über Commit → Tag → Deploy. - **Kein `git clean` auf Live.** `.env`, `storage/`, `vendor/`, `RELEASE`, `public/storage` sind ignoriert und bleiben unberührt. - **Deploy im selben Verzeichnis** mit `php artisan down` (Sekunden), keine Release-Verzeichnisse — `storage/app` bleibt an seinem Ort. - **Migrationen sind additiv.** Der alte Code muss mit dem neuen Schema laufen; dann ist der Rollback einfach der vorherige Tag. Kein `migrate:rollback` auf Live. --- ## Voraussetzungen im Repository `.gitignore` muss enthalten bzw. darf **nicht** enthalten: ```gitignore /RELEASE # schreibt deploy.sh beim Deploy /.env /vendor /node_modules /public/hot /public/storage /storage/*.key # NICHT ignorieren: /public/build (wird versioniert) ``` Prüfen, ob ein Muster versehentlich echte Dateien trifft (macOS-Muster wie `Icon` treffen bei `core.ignorecase=true` auch `…/icon/`): ```bash git ls-files --others --ignored --exclude-standard | grep -v -E '^(vendor|node_modules|storage)/' | head -50 ``` Version in der App: `vorlagen/laravel/app/Support/Monitoring/Release.php` plus ```php // config/app.php 'version' => App\Support\Monitoring\Release::current(env('APP_VERSION')), ``` `deploy.sh` nach `dev/betrieb/deploy.sh` (oder anders, dann `DEPLOY_SCRIPT` im Skript anpassen) und die Einstellungen oben setzen: ```bash DEPLOY_SCRIPT="dev/betrieb/deploy.sh" # Pfad im Repository PHP_FPM_SERVICE="php8.4-fpm" # systemd-Name ABOUT_SECTION="" # Abschnitt aus AboutCommand::add, leer = RELEASE ausgeben QUEUE_PROBE=1 # 0, wenn das Projekt keine Queue hat ``` `QUEUE_PROBE=1` setzt `monitoring:queue-probe` voraus (03). --- ## A — Einmalig: Server-Zugang zum Git-Server Auf dem Server als App-Benutzer (bei Ploi `ploi`): 1. `git --version` 2. Deploy-Key erzeugen und im Git-Server **nur lesend** am Repository eintragen: ```bash ssh-keygen -t ed25519 -f ~/.ssh/deploy_meineapp -N "" -C "meineapp-live" cat ~/.ssh/deploy_meineapp.pub ``` 3. `~/.ssh/config`: ``` Host git.example.com Port 222 User git IdentityFile ~/.ssh/deploy_meineapp IdentitiesOnly yes ``` 4. Host-Key aufnehmen, Fingerprint mit dem im Git-Server angezeigten vergleichen: ```bash ssh-keyscan -p 222 git.example.com >> ~/.ssh/known_hosts ssh-keygen -lf <(ssh-keyscan -p 222 git.example.com 2>/dev/null) ``` 5. Test: `git ls-remote ssh://git@git.example.com:222/team/meineapp.git` zeigt die Tags. 6. **FPM-Reload ohne Passwort testen** (Ploi erlaubt das dem Benutzer `ploi`): ```bash ps -o pid,lstart,args -C php-fpm8.4 | head -3 echo "" | sudo -S service php8.4-fpm reload; echo "Exit: $?" sleep 2; ps -o pid,lstart,args -C php-fpm8.4 | head -3 ``` Exit 0, `master process` mit gleicher PID, `pool www` neu → das Skript lädt FPM selbst. Fragt `sudo` nach einem Passwort: beim Deploy `DEPLOY_FPM_RELOAD=` setzen, das Skript wartet dann auf Enter, bis FPM im Panel neu geladen ist. **Immer Reload, nie Restart** — Restart entfernt kurz den Socket, Nginx liefert 502. 7. **Supervisor und Worker prüfen:** `systemctl is-enabled supervisor` → `enabled`; im Panel ein Worker mit `php artisan queue:work --queue= --tries=3 --max-time=3600`. ## B — Einmalig: Live-Verzeichnis zum Checkout machen ### B1 — Neues Projekt ```bash cd ~ && git clone ssh://git@git.example.com:222/team/meineapp.git meineapp.de cd meineapp.de && git checkout --detach v1.0.0 cp /pfad/zur/.env .env && composer install --no-dev --optimize-autoloader echo v1.0.0 > RELEASE && php artisan migrate --force && php artisan config:cache ``` ### B2 — Bestehendes Verzeichnis (bisher SFTP o. Ä.) an Ort und Stelle umwandeln Erst ein Tag `vA` erstellen, der **genau** dem Live-Stand entspricht. ```bash cd ~ && tar czf backup-vor-git.tgz --exclude=./storage --exclude=./node_modules -C ~/meineapp.de . cd ~/meineapp.de git init -q git remote add origin ssh://git@git.example.com:222/team/meineapp.git git fetch --tags origin git update-ref --no-deref HEAD "$(git rev-parse 'vA^{commit}')" git read-tree HEAD # Index = Tag, Dateien bleiben unangetastet git status --porcelain # Abweichungen sichten ``` | Zeile | Bedeutung | Vorgehen | |---|---|---| | ` M datei` | Live weicht vom Tag ab | `git diff -- datei`. Fehlt die Änderung im Repository: erst lokal nachziehen, neuer Tag. Sonst Altstand, wird überschrieben | | ` D datei` | fehlt auf Live | meist Entwicklerdatei; der Checkout legt sie an | | `?? datei` | nur auf Live | **nicht löschen** — nach `~/vor-git/` verschieben oder in `.gitignore` aufnehmen (neuer Tag) | Bekannte Funde: fehlende `.gitignore` auf Live (dann erscheinen `.env`, `vendor/` als `??` — verschwinden nach dem Checkout), per SFTP gelöschte Altdateien, Pfade mit Umlauten (Git setzt sie in Anführungszeichen — beim Filtern mit `grep` beachten). Wenn nichts Unerklärtes übrig ist: ```bash git checkout -q -f --detach vA git status --porcelain # muss leer sein ``` Beim ersten Deploy existiert `deploy.sh` evtl. noch nicht im Live-Stand: ```bash git show vB:dev/betrieb/deploy.sh > /tmp/deploy.sh && bash /tmp/deploy.sh vB ``` --- ## C — Release lokal (jedes Mal) ```bash # 1. sauber und grün git status # nichts offen php artisan test --compact --parallel && vendor/bin/phpstan analyse # 2. Assets bauen und committen npm ci && npm run build git add public/build git diff --cached --quiet public/build || git commit -m "build: Assets für vX.Y.Z" # 3. Tag mit Release-Notiz, pushen (Tags mitnehmen!) git tag -a vX.Y.Z -m "vX.Y.Z — Deploy: , bash dev/betrieb/deploy.sh vX.Y.Z [--migrations]" git push origin main --follow-tags ``` ## D — Deploy (jedes Mal, auf dem Server) 1. Bringt der Tag Migrationen mit: **DB-Backup**. 2. Neue `.env`-Werte laut Release-Notiz vorher eintragen. 3. Deploy: ```bash cd ~/meineapp.de bash dev/betrieb/deploy.sh vX.Y.Z # ohne Migrationen bash dev/betrieb/deploy.sh vX.Y.Z --migrations # mit Migrationen ``` Ablauf im Skript: | Schritt | Bei Fehler | |---|---| | Arbeitsverzeichnis sauber? | Abbruch, nichts verändert | | `git fetch --tags`, Tag vorhanden? | Abbruch, nichts verändert | | Neue Migrationen? Ohne `--migrations` | Abbruch, nichts verändert | | `down` → Checkout → `RELEASE` → `composer install` → `migrate` → Livewire-Assets → Caches → `queue:restart` → FPM-Reload → `up` | Seite bleibt im Wartungsmodus, Skript nennt den Rollback-Befehl | | Version ausgeben | — | | `monitoring:queue-probe --wait=60` | **Exit 3**: live, aber kein Worker — Supervisor/Worker prüfen | Optionen: `--clear-schedule` (setzt `withoutOverlapping`-Sperren zurück, nur wenn sich Sperrdauern geändert haben). Lange Migrationen: `DB_LOCK_WAIT_TIMEOUT=300 bash dev/betrieb/deploy.sh vX.Y.Z --migrations`. Exit-Codes: `0` fertig · `1` abgebrochen · `2` falscher Aufruf · `3` kein Worker. Hinweis: Das Skript läuft aus einer Kopie des **vorher** ausgecheckten Stands (Bash liest Skripte stückweise, Git ersetzt die Datei beim Checkout). Neue Schritte im Skript greifen also erst beim übernächsten Deploy — beim einführenden Deploy von Hand nachholen. 4. Prüfen: `/health` mit Token zeigt `"version":"vX.Y.Z"`, Uptime Kuma grün, GlitchTip ohne neue Einträge, Logs ohne neue Fehler. ## E — Rollback ```bash bash dev/betrieb/deploy.sh ``` Baut `vendor/` und Caches für den alten Tag neu auf. Nur bei Datenproblemen das DB-Backup einspielen. --- ## Fallstricke - **FPM-Restart statt Reload** → 502 für Sekunden (Nginx: `connect() to unix:…fpm.sock failed`). Einzelne `recv() failed (104: Connection reset by peer)` genau zum Deploy-Zeitpunkt sind normal (laufende Anfragen beim Reload). Abhilfe wäre `process_control_timeout = 10s` im Abschnitt `[global]` von `/etc/php/8.4/fpm/php-fpm.conf` (root) — **nie in die Pool-Datei** (`www.conf`), dort ist der Schlüssel unbekannt und FPM startet nicht mehr. - **Supervisor gestoppt** → `queue:restart` beendet den Worker, niemand startet ihn neu. Deshalb die Worker-Prüfung am Ende. Notlösung ohne sudo: `nohup php artisan queue:work --tries=3 --max-time=3600 >> storage/logs/worker-manual.log 2>&1 &` - **Nie `queue:retry all`** nach Ausfällen: alte Jobs tragen den Stand ihrer Klasse von damals und scheitern oder verschicken veraltete Mails. Erst `queue:failed` ansehen, gezielt `queue:retry `. - **Push ohne Tags** → `deploy.sh` findet den Tag nicht. Immer `--follow-tags`. - **`RELEASE` nicht von Hand neu erzeugen** auf einem anderen Stand als dem Tag (ergibt `vX.Y.Z-3-gabc-dirty`). Das Skript schreibt sie. - **Server-Panel-Logs**: Ploi führt für Sites kein Nginx-Zugriffsprotokoll; das Fehlerlog `/var/log/nginx/-error.log` ist für `ploi` lesbar.