Deployment
Hosting: Laravel Forge
Postbox laeuft auf einem Forge-managed Server. Deployments werden ueber Forge ausgeloest (Push-to-Deploy oder manuell).
PostgreSQL-Voraussetzungen
Vor dem ersten Deployment muss die pg_trgm Extension aktiviert sein (fuer Trigram-basierte Fuzzy-Suche):
# Einmalig auf dem Production-Server ausfuehren:
sudo -u postgres psql -d postbox -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
Ohne diese Extension schlaegt die Migration 2026_02_21_100000_add_trigram_indexes_for_discover fehl. Die Extension aktiviert GIN-Trigram-Indexes fuer similarity() und ILIKE-Queries auf social_profiles und watchers.
Optionaler manueller Index (nicht in Migration enthalten):
CREATE INDEX CONCURRENTLY watchers_name_trgm_index ON watchers USING gin (name gin_trgm_ops);
Deployment Script
Das Forge Deployment Script fuehrt folgende Schritte aus:
cd /home/forge/app.postbox.so/current
# Dependencies aktualisieren
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
# Migrationen ausfuehren
php artisan migrate --force
# Cache komplett zuruecksetzen — Filesystem-Caches (config, route, view, event,
# compiled) UND Application-Cache. `optimize:clear` ruft intern `cache:clear`
# auf, was auf der Cache-Connection (DB 1) ein FLUSHDB ausfuehrt → wirklich
# alles weg, frische Basis fuer den Re-Build.
# Hinweis: weil das ein FLUSHDB ist, wuerden auch fremde Apps auf derselben
# Redis-DB mitgeleert — wir nutzen Redis dediziert fuer Postbox, daher okay.
# Fuer geteilte Redis-Setups gibt es als Alternative `php artisan cache:flush-app`,
# das nur Keys mit unserem App-Prefix loescht.
# Lock-Connection (DB 0) bleibt unangetastet → Queue/Schedule-Locks ueberleben.
php artisan optimize:clear
# Caches optimieren (Config, Routes, Views)
php artisan optimize
# Log Viewer Assets publishen
php artisan vendor:publish --tag=log-viewer-assets --force
# Vite-Assets bauen
npm ci --prefer-offline
npm run build
# Queue Workers neustarten (graceful)
php artisan queue:restart
Location: Forge Dashboard > Sites > Deploy Script
Compiled Views auf lokaler NVMe (nicht auf dem CIFS-Mount)
storage/ liegt auf einem CIFS/SMB-Netzwerk-Mount (dort liegen die Profilbilder). Der Laravel-Default fuer kompilierte Blade-Views ist storage/framework/views — also auf dem Netzwerk-Mount. php artisan view:cache schreibt rund 960 kompilierte Dateien (App-Views + Flux Pro + Livewire) einzeln; ueber das Netzwerk lief der Schritt regelmaessig ins Deploy-Timeout (allein das Leeren via optimize:clear dauerte 1 Min 41 Sek, waehrend config/route/event-Cache in bootstrap/cache jeweils unter 1 ms brauchten).
Loesung: config/view.php setzt den Compiled-Pfad auf bootstrap/cache/views (lokale NVMe, pro Forge-Release isoliert). view:cache faellt damit auf rund 2 Sekunden.
// config/view.php
'compiled' => env('VIEW_COMPILED_PATH', base_path('bootstrap/cache/views')),
Verzeichnis muss existieren: view:cache schreibt nur in einen bereits vorhandenen Ordner. Forge legt bootstrap/cache an, das Unterverzeichnis views/ aber nicht zwingend — daher im Deploy-Script vor dem Cache-Build idempotent anlegen (gilt analog fuer einen tmpfs-Pfad, der nach Reboot leer ist):
mkdir -p bootstrap/cache/views
php artisan view:cache # bzw. via `php artisan optimize`
Reihenfolge der Cache-Befehle: config:cache muss vor cache:warm laufen, sonst waermt der Warmer mit ungecachter Config. php artisan optimize erledigt config/route/view/event in der richtigen Reihenfolge.
Env-Override (optional, RAM-Disk): Fuer maximale Geschwindigkeit kann der Pfad auf eine tmpfs/RAM-Disk zeigen. Der Unterschied zur lokalen NVMe ist bei ~960 kleinen Dateien aber kaum messbar — der entscheidende Gewinn ist der Wechsel weg vom Netzwerk-Mount.
# Zielordner der kompilierten Blade-Views. Default: bootstrap/cache/views (lokale NVMe).
# Optional auf tmpfs/RAM: /dev/shm/postbox-views (wird nach Reboot via view:cache neu befuellt).
VIEW_COMPILED_PATH=/dev/shm/postbox-views
Bei tmpfs gilt: Der Ordner wird bei Reboot geleert und beim naechsten Deploy (view:cache) bzw. lazy beim ersten Request neu befuellt. Auf Zero-Downtime-Setups ist der tmpfs-Pfad NICHT pro Release isoliert (alle Releases teilen ihn) — bootstrap/cache/views ist hier sauberer.
Einmalige Aufraeumung nach Umstellung: Alte kompilierte Views unter storage/framework/views werden von view:clear nicht mehr angefasst und koennen einmalig entfernt werden:
# Laeuft ueber CIFS langsam — ggf. detached starten.
find storage/framework/views -name '*.php' -delete
Queue Workers (Forge Daemons)
Alle Queue Workers laufen als Forge Daemons. Jede Queue hat einen dedizierten Worker:
# YouTube Channel Updates (--tries=0: Job steuert Retries selbst via $tries/$maxExceptions/retryUntil)
php8.4 artisan queue:work database --sleep=5 --daemon --quiet --timeout=120 --tries=0 --queue=imports-youtube
# YouTube Priority Updates
php8.4 artisan queue:work database --sleep=5 --daemon --quiet --timeout=120 --tries=0 --queue=imports-youtube-priority
# YouTube Video Stats
php8.4 artisan queue:work database --sleep=5 --daemon --quiet --timeout=120 --tries=0 --queue=imports-youtube-video
# YouTube Video Priority
php8.4 artisan queue:work database --sleep=5 --daemon --quiet --timeout=120 --tries=0 --queue=imports-youtube-video-priority
# Related Profiles (alle Plattformen, High-Prio zuerst fuer User-getriggerte Jobs)
php8.4 artisan queue:work database --sleep=5 --daemon --quiet --timeout=300 --tries=0 --queue=youtube-related-channels-high,instagram-related-profiles-high,cross-platform-related-high,youtube-related-channels,instagram-related-profiles,cross-platform-related
# AI Detection (Rate-Limited durch Job-Middleware)
php8.4 artisan queue:work database --sleep=5 --daemon --quiet --timeout=60 --tries=3 --queue=ai-detection
# E-Mail Notifications (Feedback, Alerts, Registration)
php8.4 artisan queue:work database --sleep=5 --daemon --quiet --timeout=60 --tries=5 --queue=emails
# Nightly Pipeline (Scores, Rollups, Leaderboards, Explore Metrics)
php8.4 artisan queue:work database --sleep=3 --daemon --quiet --memory=4096 --timeout=3600 --tries=3 --queue=pipeline
WICHTIG: YouTube-Queues muessen
--tries=0verwenden! Die Jobs steuern Retries selbst via$tries = 0+$maxExceptions+retryUntil(). Bei--tries=3zaehlt jederrelease()-Aufruf (Quota-Pause) als Versuch, was nach 3 Releases zuMaxAttemptsExceededExceptionfuehrt — obwohl noch genug API-Quota vorhanden ist.
| Queue | Timeout | Tries | Besonderheit |
|---|---|---|---|
imports-youtube | 120s | 0 | Job-managed Retries (Quota-Aware) |
imports-youtube-priority | 120s | 0 | PRO/Leaderboard-Profile |
imports-youtube-video | 120s | 0 | Video-Statistiken |
imports-youtube-video-priority | 120s | 0 | Prioritaets-Video-Sync |
youtube-related-channels-high | 300s | 0 | User-getriggert, hoechste Prio |
instagram-related-profiles-high | 300s | 0 | User-getriggert, hoechste Prio |
cross-platform-related-high | 300s | 0 | User-getriggert, hoechste Prio |
youtube-related-channels | 300s | 0 | AutoFill, Quota-Aware |
instagram-related-profiles | 300s | 0 | AutoFill |
cross-platform-related | 300s | 0 | AutoFill |
ai-detection | 60s | 3 | Gemini Rate-Limit: 15/min |
emails | 60s | 5 | Notification-Mails, Feedback, Admin-Alerts |
pipeline | 3600s | 3 | Nightly Pipeline, --memory=4096 (Leaderboard-Jobs bis 2400s) |
Location: Forge Dashboard > Daemons
Collector-basierte Jobs (Instagram)
Instagram Daily Scrapes laufen nicht ueber Laravel Queues, sondern ueber das Collector-System:
- Browser-Extension least Jobs via
/api/collector/jobs/lease - Ergebnisse werden via
/api/collector/jobs/{id}/completezurueckgemeldet - Kein Laravel Queue Worker erforderlich
Location: app/Http/Controllers/Api/CollectorJobController.php
Reverb Daemon
Laravel Reverb laeuft als separater Forge Daemon:
php8.4 artisan reverb:start --host=0.0.0.0 --port=8081
Nginx WebSocket Proxy
Nginx muss WebSocket-Verbindungen an Reverb weiterleiten:
location /app {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
Location: Forge Dashboard > Sites > Nginx Configuration
Vertrauenswürdige Proxies (Cloudflare)
Die App läuft hinter Cloudflare im aktiven Proxy-Modus. Ohne konfigurierte Trusted Proxies liefert $request->ip() die Cloudflare-Edge-IP statt der echten Besucher-IP — alle IP-basierten Kontrollen (Collector-Token-IP-Whitelist, last_used_ip, Login-Throttle, per-IP-Rate-Limits) würden dann auf CF-Ranges laufen und ins Leere greifen.
bootstrap/app.php konfiguriert trustProxies deshalb auf ausschließlich die Cloudflare-IP-Ranges (nicht *) plus die X-Forwarded-*-Header. Die echte Besucher-IP wird aus X-Forwarded-For aufgelöst; ein direkt an den Origin (an Cloudflare vorbei) gesendetes, gefälschtes X-Forwarded-For wird ignoriert, weil die Quelle keine vertraute CF-IP ist.
Wartung: Die Cloudflare-Ranges sind im Code hinterlegt (mit Quellkommentar). Sie ändern sich selten — bei einem Cloudflare-Update den Block in
bootstrap/app.phpgegen https://www.cloudflare.com/ips/ synchronisieren. Empfehlung: Den Origin-Firewall zusätzlich auf Cloudflare-IPs beschränken, damit der Origin nicht direkt erreichbar ist.
PHP-FPM Konfiguration
PHP-FPM verarbeitet alle HTTP-Requests (Web UI, Livewire Polling, API). Die Default-Konfiguration von Forge ist fuer Production-Workloads zu niedrig.
Location: /etc/php/8.4/fpm/pool.d/www.conf
Empfohlene Konfiguration (48-Kern-Server)
| Setting | Forge Default | Empfohlen | Beschreibung |
|---|---|---|---|
pm | dynamic | dynamic | Pool-Modus |
pm.max_children | 20 | 150 | Max. gleichzeitige Worker |
pm.start_servers | 2 | 30 | Worker beim Start |
pm.min_spare_servers | 1 | 15 | Mindest-Idle-Worker |
pm.max_spare_servers | 3 | 50 | Max. Idle-Worker |
pm.max_requests | 0 | 1000 | Requests pro Worker vor Respawn |
Sizing — messen statt Faustregel.
Die früher hier stehende Regel „max_children = RAM (GB) / 0,1 GB (bei ~100 MB pro Worker)" ist
für dieses Setup um mehr als Faktor 10 falsch und wird nicht mehr verwendet. Gemessen auf
PROD am 2026-07-31: 8 MB PSS pro Worker, 77 Worker zusammen 0,6 GB. Die Regel hätte für
48 GB RAM 480 Worker ergeben — ein Wert, der die Maschine sofort umbringt.
Der Grund für die Abweichung: ps-RSS zählt bei jedem Worker den geteilten Speicher (OPcache,
Binary) voll mit. Der ehrliche Wert ist PSS, der geteilte Seiten anteilig verrechnet:
sudo bash -c 'for p in $(pgrep -f "php-fpm: pool www"); do
awk "/^Pss:/ {s+=\$2} END {print s+0}" /proc/$p/smaps_rollup 2>/dev/null
done' | awk '{s+=$1;n++} END {printf "Worker=%d PSS-Ø=%.0f MB Summe=%.1f GB\n", n, s/n/1024, s/1048576}'
Damit lautet die Rechnung:
max_children = (RAM_gesamt − Postgres − Redis − Queue-Daemons − Reverb − OS/Page-Cache) / PSS_pro_Worker
Ob das Limit überhaupt bindet, sagt das Log — nicht die Rechnung:
sudo grep -c "reached pm.max_children" /var/log/php8.4-fpm.log
Kommt dort 0, war max_children nie der Engpass und kann gefahrlos gesenkt werden. Feuert die
Zeile regelmäßig, bedeutet Senken 502er unter Last — dann muss der Speicher pro Worker sinken,
nicht die Anzahl. (Auf PROD feuerte sie zwischen dem 28. und 31.07. sechsmal, ausschließlich im
Nacht-Pipeline-Fenster.)
Weiterhin gültig:
pm.max_requests = 1000verhindert Memory-Bloat durch langlebige Worker- Bei
active: N, idle: 0insystemctl status php8.4-fpmistmax_childrenzu niedrig - CLI und FPM haben getrennte
php.iniund unterschiedlichememory_limit-Werte (PROD 2026-07-31: CLI 512 MB, FPM 128 MB). Werphp8.4 -ifragt, bekommt die CLI-Zahl — für FPM giltgrep ^memory_limit /etc/php/8.4/fpm/php.ini.
# Aendern und neustarten
sudo nano /etc/php/8.4/fpm/pool.d/www.conf
sudo systemctl restart php8.4-fpm
sudo systemctl status php8.4-fpm
WICHTIG: Nach PHP-Upgrades (z.B. 8.3 → 8.4) pruefen ob der alte FPM-Master noch laeuft:
ps -eo pid,args | grep "php-fpm: master". Alte Version stoppen:sudo systemctl stop php8.3-fpm && sudo systemctl disable php8.3-fpm
Siehe auch: Troubleshooting fuer Diagnose bei FPM-Worker-Exhaustion.
Scheduler
Der Laravel Scheduler muss jede Minute laufen. Forge richtet den Cron automatisch ein:
* * * * * cd /home/forge/app.postbox.so/current && php8.4 artisan schedule:run >> /dev/null 2>&1
Alle Scheduled Commands sind in routes/console.php definiert. Wichtige Jobs haben Heartbeat-Monitoring via CronHeartbeatMonitorService und Overlap-Schutz via .withoutOverlapping().
Location: routes/console.php
Wichtige .env-Variablen (Production)
⚠️ Dieser Block ist eine ORIENTIERUNG, keine Abschrift der laufenden Konfiguration. Er hat schon einmal zu einer Fehldiagnose geführt:
CACHE_STORE=databasesteht hier, während Redis Cache Redis als Primär-Store mit PostgreSQL-Failover beschreibt unddatabasedort ausdrücklich der Rollback-Wert ist. Den tatsächlichen Wert vom Server lesen, nie aus einer Doku oder aus.env.exampleableiten:php8.4 artisan tinker --execute="dump(config('cache.default'), config('queue.default'), config('session.driver'), config('database.connections.pgsql.database'));"Gemessen am 2026-07-31: die Datenbank heißt
postbox_db3_prod, nichtpostbox— derCREATE EXTENSION-Befehl weiter oben zeigt auf den falschen Namen und muss beim nächsten Anfassen mitkorrigiert werden.
# App
APP_ENV=production
APP_DEBUG=false
APP_URL=https://app.postbox.so
# Datenbank
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=postbox
DB_USERNAME=forge
DB_PASSWORD=<secret>
# Queue & Cache
QUEUE_CONNECTION=database
CACHE_STORE=database
# Reverb (Production)
REVERB_APP_ID=postbox
REVERB_APP_KEY=<generated-key>
REVERB_APP_SECRET=<generated-secret>
REVERB_HOST=app.postbox.so
REVERB_PORT=443
REVERB_SCHEME=https
# Flare (Error Tracking)
FLARE_KEY=your-flare-key
# YouTube API
YOUTUBE_API_KEY=<key>
YOUTUBE_API_KEYS=<key1>,<key2>,<key3>
# Health Monitoring
HEALTH_TOKEN=<generated-hex-token>
# Auth — Google OAuth (Socialite) + Cloudflare Turnstile
GOOGLE_CLIENT_ID=<client-id>
GOOGLE_CLIENT_SECRET=<client-secret>
GOOGLE_REDIRECT_URI=${APP_URL}/auth/google/callback
TURNSTILE_ENABLED=true
TURNSTILE_SITE_KEY=<site-key>
TURNSTILE_SECRET_KEY=<secret-key>
Location: .env.example
Troubleshooting
Scheduler laeuft nicht
# Cache leeren
php artisan schedule:clear-cache
# Manuell testen
php artisan schedule:run --verbose
# Cron-Eintrag pruefen
crontab -l
Queue-Probleme
# Queue-Status pruefen
php artisan queue:monitor imports-youtube,imports-youtube-priority,ai-detection
# Fehlgeschlagene Jobs anzeigen
php artisan queue:failed
# Alle fehlgeschlagenen Jobs erneut versuchen
php artisan queue:retry all
Pending Jobs inspizieren
php8.4 artisan tinker --execute="
DB::table('jobs')
->selectRaw(\"queue, payload::json->>'displayName' as job_class, COUNT(*) as count\")
->groupBy('queue', DB::raw(\"payload::json->>'displayName'\"))
->orderByDesc('count')
->get()
->each(fn(\$r) => print(\"\$r->queue: \$r->job_class (\$r->count)\n\"));
"
Mit Alter der aeltesten Jobs:
php8.4 artisan tinker --execute="
DB::table('jobs')
->selectRaw(\"queue, payload::json->>'displayName' as job_class, MIN(to_timestamp(available_at)) as oldest, COUNT(*) as count\")
->groupBy('queue', DB::raw(\"payload::json->>'displayName'\"))
->orderByDesc('count')
->get()
->each(fn(\$r) => print(\"\$r->queue: \$r->job_class (\$r->count, oldest: \$r->oldest)\n\"));
"
Jobs die mehrere Tage alt sind deuten auf gestoppte oder gecrashe Forge Workers hin.
Jobs manuell anschieben
Wenn Forge-Worker gestoppt sind:
# Einzelne Queue abarbeiten (stoppt automatisch wenn leer)
php8.4 artisan queue:work --queue=ai-detection --stop-when-empty
# Mehrere Queues parallel
php8.4 artisan queue:work --queue=ai-detection --stop-when-empty &
php8.4 artisan queue:work --queue=imports-youtube --stop-when-empty &
--stop-when-empty verhindert Zombie-Prozesse neben den Forge-Workern.
Memory-Probleme
php -d memory_limit=512M artisan social:queue-daily-instagram
Die Scraper-Commands nutzen chunkById(1000) um Memory-Exhaustion zu vermeiden.
Lang laufende Befehle ueberleben ihr Release nicht
Bei einem Zero-Downtime-Deployment ist storage/ im Release nur ein Symlink auf den gemeinsamen Speicher, und current ist ein Symlink auf das jeweils aktive Release. Beide werden beim Deployment umgehaengt, und das alte Release wird danach geloescht.
Eine offene Shell merkt sich beim cd den LOGISCHEN Pfad, der Kernel aber den aufgeloesten. Wer vor einem Deployment cd ~/app.postbox.so/current getippt hat, steht danach weiterhin im ALTEN Release — der Prompt zeigt trotzdem current. Jeder Befehl aus dieser Shell laedt seinen Autoloader, seine Konfiguration und seinen Speicherpfad aus einem Verzeichnis, das gerade abgeraeumt wird.
⚠️ Am 05.09.2026 ist genau das passiert, und es waere fast teuer geworden. Ein neun Stunden laufender images:generate-variants schrieb nach dem Deployment weiter aus dem abgeloesten Release. Das Aufraeumen hatte dessen storage-Symlink bereits entfernt, also legte mkdir -p die Verzeichnisse echt an — auf der lokalen Platte, in einem Verzeichnis, das gerade geloescht wurde. Es blieb bei leeren Verzeichnissen (0 Dateien, 28 KB); haetten dort Varianten gelegen, haette der Verwerf-Pfad sie als „nachweislich vorhanden" gesehen, die Quelldatei geloescht, und beim naechsten Aufraeumen waeren beide weg gewesen.
Sichtbar wurde es an zwei Stellen: Das Deployment meldete rm: cannot remove '<release>': Directory not empty und schlug fehl, und der Befehl starb am Ende an seinem eigenen Autoloader (include(.../releases/<alt>/vendor/...): No such file or directory).
Nach jedem Deployment gilt fuer jede offene Shell:
cd /home/forge && cd /home/forge/app.postbox.so/current
pwd -P # muss das AKTUELLE Release zeigen
pwd allein genuegt nicht — es zeigt den logischen Pfad und damit genau die Angabe, die in die Irre fuehrt. Nur pwd -P loest auf.
Fuer Laeufe ueber Stunden gilt zusaetzlich: Sie gehoeren in einen Terminal-Multiplexer und werden nach einem Deployment neu gestartet. Die Aufraeum-Befehle dieses Projekts sind dafuer gebaut — sie fragen nach Zeilen, die noch Arbeit brauchen, statt einen Fortschritts-Zaehler zu fuehren, und sind deshalb jederzeit abbrechbar und wiederholbar.
Der Code faengt inzwischen die gefaehrlichste Folge ab: App\Services\Storage\SharedStorageGuard prueft vor jeder Existenzpruefung im Verwerf-Pfad, ob storage_path() wirklich auf dem gemeinsamen Speicher landet. Ist er es nicht, bleibt die Quelldatei liegen und der Grund steht im Log. Das ist eine Sicherung, kein Ersatz fuer die Regel oben — der Guard verhindert Datenverlust, nicht die verschwendeten Stunden eines Laufs, der ins Leere schreibt.