presseportale/dev/betrieb/deploy-runbook.md

23 KiB
Raw Blame History

Deploy-Runbook — Pressekonto (Laravel-System)

Stand: 09.07.2026 · Gilt für: das neue Laravel-System (Hub pressekonto + Spokes presseecho/businessportal24). FTP-LIVE-DEPLOY.md betrifft nur das Alt-Symfony-System und ist für dieses System nicht anwendbar.

Referenz-Plan: dev/archive/release/entwicklungsplan-release.md (R5.2). Externe Voraussetzungen (Stripe-Live-Keys, echte Mail-/Kontaktadressen, Favicons, finale Rechtstexte) siehe dort.


1. Server-Voraussetzungen

  • PHP 8.4 (FPM) mit Extensions: pdo_mysql, mbstring, intl, gd (Bildvarianten), curl, zip
  • MySQL/MariaDB (Prod: DB_CONNECTION=mysql; SQLite ist nur Dev)
  • Node.js (LTS) + npm — nur zum Bauen der Assets (Builds sind gitignored, siehe Schritt 3)
  • Webserver-vHosts für alle drei Domains auf dasselbe public/ (Host-Routing macht die App)
  • HTTPS-Zertifikate für alle drei Domains
  • Prozess-Manager (systemd/Supervisor) für den Queue-Worker

Wichtig (SEO): Keine statische public/robots.txt anlegen — robots.txt und sitemap.xml sind dynamische, host-aware Laravel-Routen; eine echte Datei würde sie verschatten.

2. .env (Produktion) — Solltabelle

Basis: .env.example kopieren und anpassen. Abweichend von den Dev-Defaults zwingend:

Key Prod-Wert
APP_ENV production
APP_DEBUG false
APP_KEY php artisan key:generate (einmalig)
APP_URL / APP_PORTAL_* / APP_PRESSEECHO_* / APP_BUSINESSPORTAL_* echte Domains (pressekonto.de, presseecho.de, businessportal24.com)
SANCTUM_STATEFUL_DOMAINS echte Domains, kommasepariert
DB_CONNECTION + DB_* MySQL-Zugangsdaten
MAIL_MAILER + MAIL_* echter Mailer — Default log versendet nichts!
MAIL_FROM_ADDRESS echte Absenderadresse
LOG_STACK daily (der Default single wächst unbegrenzt; daily rotiert mit LOG_DAILY_DAYS, Default 14)
MONITORING_ALERT_EMAIL Ops-Postfach für Betriebs-Alerts (R5.3); leer = Fallback auf LEGAL_CONTACT_EMAIL
SESSION_SECURE_COOKIE true
BASIC_AUTH_ENABLED Staging: true + User/Passwort · Live: false
BILLING_ENFORCE_BOOKING true (Default)
STRIPE_KEY/SECRET/WEBHOOK_SECRET + STRIPE_PRICE_SINGLE_PM Live-Werte [EXTERN R1.2]
BILLING_OWN_VAT_ID echte USt-IdNr.
OPENAI_API_KEY Prod-Key (ohne ihn keine Klassifikation/kein Score)
CONTACT_EMAIL_* echte Empfängeradressen [EXTERN R2.1]
GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI Live-OAuth-Client (Redirect-URI exakt)

3. Build & Release-Schritte

# 1) Code auslegen (git pull / Release-Verzeichnis)
composer install --no-dev --optimize-autoloader

# 2) Assets bauen (Builds sind gitignored — auf dem Server oder in CI bauen
#    und public/build/ mit ausliefern)
npm ci
npm run build            # baut portal + web (public/build/portal, public/build/web)

# 3) Storage-Symlink (Firmenlogos, PM-Bilder)
php artisan storage:link

# 4) Datenbank
php artisan migrate --force

# 5) Seeder — NUR die Stammdaten-Seeder. DatabaseSeeder ist prod-sicher
#    (Demo-User nur in local/testing), aber explizit ist besser:
php artisan db:seed --class=RolesAndPermissionsSeeder --force
php artisan db:seed --class=AdminPresetSeeder --force
php artisan db:seed --class=PlanSeeder --force
php artisan db:seed --class=PaymentOptionSeeder --force
php artisan db:seed --class=LegalDocumentSeeder --force
php artisan db:seed --class=RejectionCategorySeeder --force

# 5a) Nach frischem Seeding (Erst-Einrichtung oder DB-Reset): Stripe-Produkte/
#     Preise neu verknüpfen — die Price-IDs stehen in der plans-Tabelle:
# php artisan billing:sync-stripe-plans

# 6) Caches bauen (bei JEDEM Deploy neu)
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

# 7) Queue-Worker neu starten (lädt neuen Code)
php artisan queue:restart

3a. Achtung: Migrationen auf press_releases (FULLTEXT erzwingt Tabellen-Kopie)

press_releases trägt zwei FULLTEXT-Indizes (press_releases_admin_search_fulltext auf title+keywords, press_releases_web_search_fulltext auf title+subtitle+text+keywords). Deshalb lehnt MySQL für jede Spaltenänderung ALGORITHM=INSTANT und INPLACE ab ("InnoDB presently supports one FULLTEXT index creation at a time") und fällt auf ALGORITHM=COPY zurück — bei importiertem Bestand (~175k Zeilen, schwere Textspalten) lief das auf Dev mit ~30 Zeilen/s, also 1,5 h+ pro ALTER (beobachtet 08./09.07.2026 bei der KI-Kennzeichnungs-Migration). Nach dem Legacy-Import gilt das auf Prod genauso.

Bewährter Ablauf für solche Migrationen (Dev-erprobt, gesamt ~6 Min statt Stunden):

-- 1) FULLTEXT-Indizes droppen (Suche ist solange offline!)
ALTER TABLE press_releases DROP INDEX press_releases_admin_search_fulltext;
ALTER TABLE press_releases DROP INDEX press_releases_web_search_fulltext;

-- 2) Migration laufen lassen: php artisan migrate --force
--    (Spalten-ALTER geht jetzt per INPLACE in ~40 s; INSTANT bleibt wegen
--    der versteckten FTS_DOC_ID-Spalte auch ohne Indizes gesperrt)

-- 3) Indizes NACHEINANDER neu aufbauen (InnoDB baut nur einen FULLTEXT gleichzeitig;
--    Dev-Zeiten: admin ~40 s, web ~4,5 Min)
ALTER TABLE press_releases ADD FULLTEXT press_releases_admin_search_fulltext (title, keywords);
ALTER TABLE press_releases ADD FULLTEXT press_releases_web_search_fulltext (title, subtitle, text, keywords);

Dazu beachten:

  • Wartungsfenster einplanen: zwischen Schritt 1 und 3 liefern Admin- und Web-Suche Fehler/keine Treffer. Alternativ die volle COPY-Dauer akzeptieren (Suche bleibt online, Schreibzugriffe auf PMs sind währenddessen aber gesperrt).
  • DB-Backup vorher (gilt ohnehin, siehe Abschnitt 9).
  • Solche Migrationen wiederaufnehmbar schreiben (Schema::hasColumn-Guards, kein ->after()), Vorlage: 2026_07_08_172023_add_ai_declaration_to_press_releases.php. Hintergrund: Laravel legt pro Spalte ein eigenes ALTER ab — bricht der Prozess mittendrin ab, ist die Migration halb angewandt und ohne Guards nicht wiederholbar.
  • Einen laufenden COPY-ALTER nicht clientseitig killen: der Server-Thread läuft als Zombie weiter und Laravel reiht das Statement nach KILL <id> per Lost-Connection-Retry erneut ein — es stauen sich doppelte ALTERs am Metadata-Lock. Falls doch nötig: in MySQL SHOW PROCESSLIST prüfen und alle betroffenen Threads killen.
  • Beim allerersten Go-Live ist das unkritisch: php artisan migrate läuft dort vor dem Legacy-Import auf leeren Tabellen (Reihenfolge aus Abschnitt 7 einhalten).

4. Queue-Worker (Pflicht)

Die KI-Jobs (ClassifyPressRelease, ScorePressRelease) laufen auf der database-Queue classification — ohne Worker bleibt jede eingereichte PM in der Prüfung hängen.

systemd-Beispiel (/etc/systemd/system/pressekonto-worker.service):

[Unit]
Description=Pressekonto Queue Worker
After=network.target

[Service]
User=www-data
Restart=always
ExecStart=/usr/bin/php /var/www/pressekonto/artisan queue:work database \
  --queue=classification,default --tries=3 --max-time=3600

[Install]
WantedBy=multi-user.target

Zeitbudgets (Audit F17, 18.09.2026): Die KI-Jobs (ClassifyPressRelease, ScorePressRelease) tragen $timeout = 120, weil drei HTTP-Versuche à 30 s die 60 s des Worker-Defaults sprengen. retry_after der Datenbank-Queue steht deshalb auf 150 (config/queue.php, überschreibbar per DB_QUEUE_RETRY_AFTER). Wer einen zweiten Worker startet, hält retry_after über dem größten Job-Timeout — sonst greift ein Worker den noch laufenden Job des anderen.

5. Scheduler / Cron (Pflicht)

Crontab des Web-Users:

* * * * * cd /var/www/pressekonto && php artisan schedule:run >> /dev/null 2>&1

Aktive Tasks (php artisan schedule:list, Stand 29.09.2026 — maßgeblich ist routes/console.php; ScheduleConfigurationTest prüft Sperrdauer und Minutenkollisionen):

Zeitplan Task Ohne Cron passiert …
jede Minute press-releases:aggregate-views Aufrufzahlen bleiben stehen (Ereignisse sammeln sich)
alle 5 Min press-releases:publish-scheduled geplante PMs werden nie veröffentlicht
alle 10 Min mail:fetch-bounces Rückläufer werden nicht gesperrt
alle 15 Min market:refresh Börsendaten im Ticker veralten
täglich 03:00 magic-links:purge --days=30 Token-Tabelle wächst
täglich 03:10 snapshots:purge Schwärzungs-Snapshots bleiben über 30 Tage liegen
täglich 03:20 archive:purge Beweisarchiv hält Einträge über die Frist
täglich 03:25 conversion:purge-events Konversionsereignisse und email_bounces über 395 Tage
täglich 03:30 archive:export-tombstones Restore-Abgleich ohne aktuellen Löschstand
täglich 03:35 archive:retry-pending offene Archivaufträge werden nicht nachgeholt
täglich 03:40 archive:sweep-orphans verwaiste Archivdateien bleiben (Quarantäne statt Löschen)
täglich 03:50 press-releases:purge-events Aufruf-, Crawler- und Feed-Protokolle über 395 Tage
täglich 04:15 billing:expire-grandfathering abgelaufene Bestandsschutz-Verträge laufen weiter
täglich 04:20 mail:check-domains --stale --limit=500 DNS-Befunde der Kontaktdomains veralten
täglich 04:30 billing:generate-manual-invoices manuelle Rechnungen fehlen
täglich 05:10 press-releases:check-index-status Indexierungsbericht bekommt keine neuen Daten
So 04:00 press-releases:purge-drafts --days=180 Alt-Entwürfe bleiben liegen
So 04:45 press-release-images:sweep-orphan-originals --execute --hours=48 Originale abgebrochener Uploads bleiben liegen
monatl. 1. 00:05 press-releases:reset-monthly-quota Kontingente werden nie zurückgesetzt

Jeder Task hält seine withoutOverlapping()-Sperre nur kurz über der erwarteten Laufzeit (Audit K-09). Nach einem Absturz mitten im Lauf gibt spätestens dann der nächste Lauf wieder frei; sofort mit php artisan schedule:clear-cache.

Monitoring (R5.3): Jeder Scheduler-Task hat ein ->onFailure(), der Queue-Worker einen Queue::failing()-Listener. Fehlschläge werden als critical ins Tageslog geschrieben und an MONITORING_ALERT_EMAIL gemailt (Fallback LEGAL_CONTACT_EMAIL; abschaltbar über MONITORING_ALERTS_ENABLED=false). Die Mail ist Best-Effort — der Log-Eintrag ist die verlässliche Spur, daher LOG_STACK=daily setzen.

6. Stripe-Webhook

Endpoint in Stripe (Live-Mode) registrieren: https://pressekonto.de/stripe/webhook → Signing-Secret als STRIPE_WEBHOOK_SECRET in die .env. Events: die Cashier-Defaults (Subscriptions, Invoices, Payment-Intents).

7. Erst-Einrichtung (nur beim allerersten Go-Live)

Bilddaten per rsync kopieren (Zugangsdaten stehen im Passwortmanager, nicht im Repository)

rsync -avz --partial ssh-w01eb4f6@w008b36f.kasserver.com:/www/htdocs/w01eb4f6/presseecho.de/web/thumbnails/
dev/migration/presseecho/thumbnails/

rsync -avz --partial ssh-w01eb4f6@w008b36f.kasserver.com:/www/htdocs/w01eb4f6/presseecho.de/web/uploads/
dev/migration/presseecho/uploads/

rsync -avz --partial ssh-w01eb4f6@w008b36f.kasserver.com:/www/htdocs/w01eb4f6/businessportal24.com/web/thumbnails/
dev/migration/businessportal24/thumbnails/

rsync -avz --partial ssh-w01eb4f6@w008b36f.kasserver.com:/www/htdocs/w01eb4f6/businessportal24.com/web/uploads/
dev/migration/businessportal24/uploads/

  1. Legacy-Datenimport (Reihenfolge beachten, read-only-Quellen konfiguriert): legacy:import → legacy:fix-timestamps → legacy:migrate-media (bzw. legacy:sync-company-logos / legacy:sync-press-release-images) → legacy:archive-invoices → legacy:grandfather-subscriptions.

  2. Agentur-Attribution (läuft NACH dem Firmen-/User-Import aus Schritt 1, Reihenfolge beachten): legacy:migrate-agency-names → companies:retire-agency-type. Beide erst mit --dry-run (Reports unter storage/app/private/migration/ prüfen, insbes. Mehrfach-Namen, Owner-lose Agentur-Firmen und geschützte Fälle), dann scharf. Namen werden nie überschrieben; gelöscht wird nur soft und nur ohne PMs/Kontakte/Dritt-Mitgliedschaften/Zahlungsoptions-Verknüpfung.

  3. Ersten Admin-User anlegen (Prod hat keine Demo-User): normal registrieren (inkl. E-Mail-Verifizierung), dann Rolle via Tinker setzen — syncRoles ersetzt dabei alle bisherigen Rollen des Users:

    php artisan tinker --execute 'App\Models\User::where("email", "mail@example.com")->firstOrFail()->syncRoles(["admin"]);'
    
  4. Favicons je Domain deployen (Platzhalter-Generator reicht nicht) [EXTERN R5.4].

  5. Smoke-Test: siehe Abschnitt 8.

  6. Medien migrieren

Erst prüfen, ob die Ordnerstruktur stimmt (zählt "Datei fehlt"/"Ziel fehlt")

php artisan legacy:migrate-media --portal=all --dry-run

Dann scharf (kopiert + erzeugt Bildvarianten — dauert bei der Menge eine Weile)

php artisan legacy:migrate-media --portal=all Kleine „Datei fehlt"-Restmengen sind bei Altbeständen normal (verwaiste DB-Einträge); große Zahlen heißen Ordnerproblem — dann melde dich mit der Ausgabe.

  1. PM-Medien privatisieren (Pflicht, direkt danach)

php artisan press-releases:privatize-media Verschiebt die PM-Bilder von der public- auf die private-Disk (Security-Hardening). Muss nach migrate-media laufen — und nach jedem späteren Medien-Lauf erneut.

  1. Timestamps korrigieren (bewusst NACH den Medien)

php artisan legacy:fix-timestamps Setzt created_at/updated_at/published_at authoritativ aus den Legacy-DBs. Nach den Medien deshalb, weil die Medien-Migration die Datensätze anfasst und updated_at neu stempelt — der Fix räumt das wieder auf. Wichtig fürs Frontend: Die Sortierung hängt an published_at.

  1. Satelliten-Migrationen (jeweils erst --dry-run, dann scharf)

php artisan legacy:import-api-keys --dry-run # lokal waren es 406 Keys — Zahl vergleichen php artisan legacy:archive-invoices --dry-run php artisan legacy:grandfather-subscriptions --dry-run # MUSS nach archive-invoices (leitet sich aus dem Rechnungsarchiv ab) php artisan legacy:import-billing-addresses --dry-run # Report: Kunden mit leeren/kaputten Alt-Adressen → manuell nachpflegen php artisan legacy:migrate-agency-names --dry-run php artisan companies:retire-agency-type --dry-run # NACH migrate-agency-names Die Reports landen unter storage/app/private/migration/ — besonders bei den letzten beiden reinschauen (Mehrfach-Namen, Owner-lose Agentur-Firmen), bevor du sie scharf laufen lässt.

  1. Das Gate: Gesamtverifikation

php artisan legacy:verify JSON-Report prüfen → das ist deine Go/No-Go-Entscheidung für den Cutover. Wenn du magst, reich mir den Report hier rein, ich gehe ihn mit dir durch.

Danach: Stichproben im Browser Newsroom-Übersicht, eine alte PM-Detailseite (lädt Bild + Anhang?), Firmenlogo, Archiv — und ein Blick in Admin ▸ Reports ▸ Betriebsüberwachung (alle Kacheln grün, besonders „Medien-Privatisierung").

8. Smoke-Test nach jedem Deploy

  • Alle drei Domains laden (Hub-Landing, 2× Spoke-Home) über HTTPS
  • /robots.txt + /sitemap.xml je Domain liefern hostkorrekte Inhalte
  • Login am Portal funktioniert; /admin erreichbar
  • Eine Test-PM einreichen → Klassifikation läuft durch (Worker-Check)
  • Kontaktformular + Widerrufs-/Kündigungsformular versenden Mails (echter Mailer!)
  • Stripe-Checkout im Live-Mode mit kleiner Testbuchung + Webhook-Eingang prüfen
  • storage/logs/laravel.log auf Fehler prüfen

9. Rollback

  • Code: vorheriges Release auschecken → Schritte 6–7 aus Abschnitt 3 wiederholen (config:cache … queue:restart).
  • Migrationen sind additiv gehalten; kein automatisches migrate:rollback in Prod — im Fehlerfall DB-Backup einspielen (Backup vor jedem Deploy mit Migrationen!).
  • Nach jedem DB-Restore — Beweisarchiv in dieser Reihenfolge (Umsetzungsaudit 29.09.2026, A-03/A-06/A-15):
    1. Sofort php artisan archive:sweep-orphans --pause (der Sweep läuft nachts um 03:40; nach einem DB-Restore fehlen jüngeren Einträgen die Zeilen, ihre Payloads sähen verwaist aus). Seit 29.09. verschiebt der Sweep ohnehin nur in die Quarantäne (storage/app/archive/quarantine/JJJJ-MM-TT/…, ARCHIVE_QUARANTINE_DAYS, Standard 30 Tage) — die Pause verhindert auch das. Harte Alternative ohne DB: ARCHIVE_SWEEP_PAUSED=true + config:cache.
    2. php artisan archive:reconcile-after-restore — liest den täglich exportierten Tombstone-Stand (tombstones/latest.json, seit 29.09. mit HMAC) und die Tombstone-Zeilen der Datenbank, entfernt wiederhergestellte Payloads erneut und löscht die mit dem Backup zurückgekommenen Kopien in der Anwendung (Bestätigungen, Korrekturen, weich gelöschte Bildzeile) erneut. Setzt den Sweep selbst aus. Zuordnung über den Payload-Pfad, nicht die ID: passt eine Export-Zeile zu einer DB-Zeile mit gleicher ID, aber anderem Payload, wird nichts angefasst („passen nicht zur Datenbank", Exit ≠ 0, Log prüfen). „Signatur ungültig" = Export verworfen, nur DB-Tombstones abgeglichen, Exit ≠ 0 — Export-Herkunft prüfen; fehlt ein Schlüssel in ARCHIVE_PREVIOUS_KEYS? Ein unsignierter Export wird seit 02.10.2026 ebenfalls verworfen (ein entferntes hmac sähe genauso aus); nur wenn er nachweislich von vor dem 29.09.2026 stammt, mit --accept-unsigned wiederholen. Exit ≠ 0 sonst: mindestens eine Datei ließ sich nicht löschen — Log prüfen, Lauf wiederholen.
    3. php artisan archive:reregister --dry-run, dann php artisan archive:reregister — legt für Payloads ohne Zeile (Einträge nach dem Backup-Zeitpunkt, auch aus der Quarantäne) die Zeile aus manifest.json wieder an. Laut Tombstone-Export gelöschte Payloads bleiben außen vor. Bricht ab, wenn der Export ungültig oder unsigniert ist (wie Schritt 2, --accept-unsigned nur für Altexporte) oder ganz fehlt — ohne Export lassen sich gelöschte Einträge nicht von verlorenen unterscheiden; nur bewusst mit --without-export. Legal Hold steht nicht im Manifest: wiederhergestellte Einträge (Zusammenfassung reregistered_at) im Admin prüfen.
    4. php artisan archive:retry-pending (offene Archivaufträge nachholen; Zähler im Admin-Systemstatus) und php artisan archive:sweep-orphans --dry-run.
    5. php artisan archive:sweep-orphans --resume — Kachel „Beweisarchiv-Orphan-Sweep" im Systemstatus wird wieder grün.
  • Achtung bei Restore der Archiv-Disk: Wird storage/app/archive selbst auf einen älteren Stand zurückgesetzt, ist auch tombstones/latest.json alt. Dann vor dem Abgleich den jüngsten Tombstone-Export von außerhalb des Hosts einspielen (Kopie aus dem letzten Backup-Lauf nach dem letzten archive:purge) oder sicherstellen, dass die Datenbank den aktuellen Stand hat — der Abgleich nimmt dann die DB-Tombstones. Ein automatisches externes Exportziel ist nicht gebaut (Betreiberentscheidung offen, Umsetzungsaudit 25.09.2026, 5.1 c).
  • ARCHIVE_KEY (Beweisarchiv) und CONFIRMATION_IP_KEY liegen nur in der Ploi-Umgebung. ARCHIVE_KEY getrennt vom DB-Backup sichern — ohne ihn ist das Archiv unlesbar; erzeugen mit php artisan archive:key. Fehlt er (z. B. nach config:cache ohne Schlüssel), meldet der Systemstatus „Beweisarchiv-Schlüssel" kritisch und archive:retry-pending bricht ab, ohne Aufträge anzufassen.
  • Schlüsselrotation ARCHIVE_KEY (A-15): neuen Schlüssel mit php artisan archive:key erzeugen und getrennt sichern; den bisherigen Wert vorne in ARCHIVE_PREVIOUS_KEYS (kommagetrennt) eintragen, den neuen als ARCHIVE_KEY setzen, config:cache, queue:restart. Jeder Eintrag trägt die ID seines Schlüssels (archive_entries.key_id); alte Einträge bleiben mit dem früheren Schlüssel lesbar, neue werden mit dem neuen verschlüsselt. Einen früheren Schlüssel nur entfernen, wenn der Systemstatus keine Einträge mit unbekanntem Schlüssel meldet (praktisch: erst nach Ablauf aller damit verschlüsselten Einträge). Anwaltsexporte werden mit einem per HKDF abgeleiteten Schlüssel signiert; php artisan archive:verify-export <zip> prüft neue und alte Exporte (vor dem 29.09. mit dem Archivschlüssel signiert) gegen alle bekannten Schlüssel.

10. Betrieb nach Go-Live (Stand 20.07.2026 — System ist LIVE)

Live-Setup: Hetzner-Cloud-Server via Ploi, App unter ~/pressekonto.com, Deploys liefen bis v1.1.0 per SFTP-Datei-Upload. Seit v1.1.3 (06.10.2026) per Git-Tag mit dev/betrieb/deploy.sh; Ablauf, einmalige Umstellung und Rollback in dev/betrieb/git-deploy.md. Die SFTP-Checkliste unten ist historisch. Auf Live nichts mehr per SFTP in ~/pressekonto.com ablegen, auch keine Doku, sonst stoppt der nächste Deploy an der Abweichungsprüfung. Echte Domains (pressekonto.com, presseecho.de, businessportal24.com) inkl. www-DNS geschaltet, Stripe im Live-Mode, legacy:verify 0 failures. Migration/Cutover: erledigt, Doku archiviert unter dev/archive/optimize/cutover-runbook.md.

SFTP-Deploy-Checkliste (jedes Mal)

  1. Version: Ausgeliefert wird ein Git-Tag nach SemVer (v1.1.0; Feature-Deploy mittlere Stelle, Hotfix letzte). Lokal git describe --tags --always --dirty > RELEASE und die Datei RELEASE mit hochladen — daraus lesen config('app.version'), GlitchTip, /health und der Systemstatus die Version. Ablauf eines Deploys mit Paket statt Einzeldateien: dev/betrieb/deploy-v1.1.0.md.
  2. Geänderte Dateien hochladen. Bei geändertem composer.lock: composer install --no-dev --optimize-autoloader, danach — falls public/vendor/livewire/ existiert — php artisan vendor:publish --tag=livewire:assets --force.
  3. php artisan view:clear && php artisan view:cache — immer bei Blade-/Volt-Änderungen (auch Livewire-Komponenten sind Blade-Dateien).
  4. php artisan config:cache — nur bei .env- oder config/-Änderungen.
  5. php artisan migrate --force — nur wenn neue Migrationen dabei sind (vorher DB-Backup).
  6. php artisan queue:restart — immer, wenn PHP-Dateien dabei waren. Der Worker ist ein Daemon und hält alten Code im Speicher; ohne Restart laufen Jobs (KI-Prüfung, Mails) mit dem alten Stand.

Betriebs-Erkenntnisse aus der Go-Live-Woche

  • Worker-Queues: Der Worker MUSS --queue=classification,default fahren (Abschnitt 4) — sonst bleiben die KI-Jobs liegen und eingereichte PMs hängen unbemerkt in der Prüfung.
  • Nach MySQL-Neustarts hängen Worker mitunter in kaputten Verbindungen → Worker in Ploi neu starten, danach ggf. php artisan queue:retry all.
  • Öffentliche Seitendaten laufen über Stale-while-revalidate-Caching (App\Support\WebPageCache, Cache::flexible) — nach cache:clear ist der jeweils erste Aufruf pro Seite langsam (Kaltaufbau), danach dauerhaft schnell.
  • www-Hosts werden zweifach behandelt: RedirectWwwToApex-Middleware (301 auf die Apex-Domain) plus Host-Normalisierung in DomainAssetContext als Sicherheitsnetz. Ploi kann keine Host-Redirects (nur Pfad-Redirects).
  • Betriebsüberwachung: Admin ▸ Reports ▸ Betriebsüberwachung (Queue-Worker, Medien-Privatisierung u. a.) regelmäßig prüfen; Fehlerlog unter storage/logs/laravel.log.
  • Offene Punkte und nächste Entwicklungen: dev/roadmap.md.