presseportale/dev/betrieb/deploy-runbook.md
Kevin Adametz 6a4c4d8837 docs: dev-Doku für den Live-Betrieb neu sortiert — Archiv + Betrieb + Roadmap
Das System ist seit 20.07.2026 live; die dev-Doku spiegelt jetzt den
Betriebszustand statt der Release-Vorbereitung:

- dev/betrieb/: Deploy-Runbook (neu: §10 Betrieb nach Go-Live mit
  SFTP-Deploy-Checkliste inkl. queue:restart-Pflicht und den
  Betriebs-Erkenntnissen der Go-Live-Woche), Legacy-API-Parität, Umami.
- dev/roadmap.md: offene Nachlauf-Punkte (Go-Live-Mails, Merge nach main,
  Stripe-Test-Artefakte, Key-Rotation, Alt-System-Abschaltung) und
  Ideen-Backlog.
- dev/archive/: abgeschlossene Phasen — Migration-2026-Planung,
  Cutover-Runbook (mit Erledigt-Vermerk), Release-Plan/QA-Checkliste,
  Optimierungsphasen, Frontend-Konzepte + Responsive-Audits vom 16.07.,
  Altsystem-Reste.
- dev/README.md als Wegweiser; gitignore-Pfad der SQL-Dumps mitgezogen
  (dev/archive/migration-2026/sql/ bleibt untracked, ebenso dev/migration/
  und dev/api/).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 07:52:38 +00:00

16 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/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

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):

Zeitplan Task Ohne Cron passiert …
alle 5 Min press-releases:publish-scheduled geplante PMs werden nie veröffentlicht
täglich 03:00 magic-links:purge --days=30 Token-Tabelle wächst
täglich 04:30 billing:generate-manual-invoices manuelle Rechnungen fehlen
So 04:00 press-releases:purge-drafts --days=180 Alt-Entwürfe bleiben liegen
monatl. 1. 00:05 press-releases:reset-monthly-quota Kontingente werden nie zurückgesetzt
alle 15 Min market:refresh Börsendaten im Ticker veralten

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)

kopieren der Bilddaten per rync #@jk:8#PN§!4V02wR:rV#

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!).

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

Live-Setup: Hetzner-Cloud-Server via Ploi, App unter ~/pressekonto.com, Deploys laufen per SFTP-Datei-Upload (kein Git-Deploy). 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. Geänderte Dateien hochladen.
  2. php artisan view:clear && php artisan view:cache — immer bei Blade-/Volt-Änderungen (auch Livewire-Komponenten sind Blade-Dateien).
  3. php artisan config:cache — nur bei .env- oder config/-Änderungen.
  4. php artisan migrate --force — nur wenn neue Migrationen dabei sind (vorher DB-Backup).
  5. 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.