# Deploy auf All-Inkl (Shared Hosting, SSH + Cronjobs) Schritt für Schritt vom leeren Webspace zur Live-Seite. Die Reihenfolge ist Absicht: **die alte CMS-Seite bleibt online, bis die neue auf einer geschützten Test-Subdomain nachweislich läuft.** Umgeschaltet wird erst am Ende, und das Umschalten ist in einer Minute rückgängig. Der Kern des Plans: die Test-Subdomain läuft mit **derselben** `config.php`, die später live geht. Es gibt keinen Config-Wechsel beim Umschalten, also auch keinen Fehler, der sich dabei einschleichen kann. Möglich ist das, weil die Host-Weiterleitung in `public/.htaccess` nur beim exakten Host `tsv08kulmbach.de` greift; eine Subdomain läuft unbehelligt durch. Einzige Abweichung: Canonical, OG und Sitemap nennen auf der Test-Subdomain schon die echte Domain. Genau richtig für den Umschaltmoment, und dank Verzeichnisschutz sieht es ohnehin keine Suchmaschine. Legende: `[lokal]` auf deinem Mac, `[SSH]` in der Server-Shell, `[KAS]` im All-Inkl-Kundenmenü. --- ## Teil A — Vorbereitung lokal ### A1 [lokal] Die zwei offenen Werte in `config/config.production.php` setzen `php bin/preflight.php` bricht sonst auf dem Server ab. Beides betrifft nur die Dateiablage `/dateien`, nicht die Website selbst. **Passwörter für die zwei Zugänge.** Als Hash, nicht als Klartext: ```bash php -r 'echo password_hash("DEIN-PASSWORT", PASSWORD_DEFAULT), "\n";' ``` Ausgabe (beginnt mit `$2y$…`) in `dateien.users.verein.password_hash` bzw. `dateien.users.admin.password_hash` eintragen. Zwei verschiedene Passwörter: `verein` gibt der Verein weiter, `admin` behältst du. **Lizenzschlüssel** der Files Gallery in `dateien.license_key`. Fehlt er, läuft die Ablage trotzdem, zeigt aber den Hinweis der unlizenzierten Version (nur eine Warnung in `preflight`, kein Blocker). Bereits erledigt: `cron.key` ist auf `''` gesetzt und der Endpoint `public/cron.php` damit zu. Der Takt kommt ab jetzt aus echten Cronjobs (Teil D), der Endpoint war nur der Ersatz für den fehlenden Cron. ### A2 [lokal] Alles gepusht? ```bash git status --porcelain # muss leer sein git push origin main ``` --- ## Teil B — Server einrichten ### B1 [SSH] Umgebung feststellen und notieren ```bash ssh @.kasserver.com pwd # z. B. /www/htdocs/w01ca75d → das ist HOME which php; php -v # PHP muss >= 8.1 sein git --version ``` **Ergebnis vom 31.07.2026 auf diesem Webspace:** | | | |---|---| | HOME | `/www/htdocs/w01ca75d` | | SITE | `/www/htdocs/w01ca75d/tsv08kulmbach-website` | | PHP (CLI, für Cron) | `/usr/bin/php` — 8.3.29 | | Composer | 2.9.8, global vorhanden | | Alte CMS-Seite (Rückweg) | `/www/htdocs/w01ca75d/tsv08kulmbach.de` | All-Inkl legt pro Domain einen Ordner im Home an, das Home selbst ist also kein Docroot. Der Ordnername `tsv08kulmbach.de` ist von der alten Seite belegt, deshalb heißt der neue `tsv08kulmbach-website`. Die anderen TSV-Ordner (`2024.`, `hall-of-fame.`, `kabinendienst.`, `kreisliga-reise.`) sind eigene Subdomains und vom Umschalten nicht betroffen. Achtung: das ist die **CLI**-Version. Welches PHP der Webserver nimmt, stellt KAS pro Domain getrennt ein (Schritt C1). ### B2 [SSH] Repo klonen ```bash cd ~ git clone https://onion.breadcrumb-online.de/fs/tsv08kulmbach-website.git tsv08kulmbach-website cd tsv08kulmbach-website && pwd # diesen Pfad notieren: das ist SITE ``` Der Klon lädt gut 460 MB (Website plus das Vereinsmaterial der Dateiablage), das dauert ein paar Minuten. Die alte CMS-Seite liegt in einem anderen Verzeichnis und wird nicht angefasst. ### B3 [SSH] `config.php` platzieren Die Datei ist gitignored, also nicht im Klon. Vom Mac hochladen: ```bash # [lokal], in einem zweiten Terminal scp config/config.production.php \ @.kasserver.com:~/tsv08kulmbach-website/config/config.php ``` Wichtig: Ziel heißt `config.php`, nicht `config.production.php`. Danach [SSH]: ```bash chmod 600 config/config.php # enthält SMTP-Passwort und app_secret ``` ### B4 [SSH] Composer-Abhängigkeiten ```bash cd ~/tsv08kulmbach-website composer install --no-dev --optimize-autoloader ``` Kennt die Shell `composer` nicht, hol ihn dir lokal ins Verzeichnis: ```bash curl -sS https://getcomposer.org/installer | php php composer.phar install --no-dev --optimize-autoloader rm composer.phar ``` Es wird nur PHPMailer installiert. ### B5 [SSH] Schreibrechte prüfen PHP läuft unter deinem Benutzer, meist passt es also schon. `preflight` sagt es dir in Teil C4 verbindlich. Falls dort etwas fehlt: ```bash chmod -R u+rwX storage ``` Schreiben muss die Seite in `storage/logs`, `storage/cache`, `storage/ratelimit`, `storage/dateien/uploads` und `storage/dateien/system`. --- ## Teil C — Auf der Test-Subdomain prüfen ### C1 [KAS] Subdomain anlegen und schützen 1. **Domains** → Subdomain `neu.tsv08kulmbach.de` anlegen. 2. Als Verzeichnis **`/tsv08kulmbach-website/public`** eintragen, nicht das Repo-Root. Damit liegen `app/`, `config/`, `data/`, `storage/` und `vendor/` außerhalb des Docroots und sind grundsätzlich nicht per URL erreichbar. (Die Root-`.htaccess` ist nur ein Notnagel für Hoster, die das nicht können.) 3. **PHP-Version** für die Subdomain auf 8.1 oder neuer stellen. 4. **SSL** aktivieren (Let's Encrypt). Ohne Zertifikat läuft die Seite in eine Endlosweiterleitung, denn `public/.htaccess` erzwingt HTTPS. 5. **Verzeichnisschutz** für die Subdomain einschalten (Basic Auth). Das ist der Grund, warum hier nichts indexiert werden kann. Nicht weglassen. ### C2 Erreichbarkeit `https://neu.tsv08kulmbach.de` aufrufen. Nach der Passwortabfrage muss die Startseite kommen. Weißes Blatt oder 500er? Dann [SSH] `tail -30 storage/logs/php-errors.log`. ### C3 [SSH] Datenstände erzeugen `data/matchcenter.json` und `data/instagram.json` sind bewusst nicht im Repo, die holen die Syncs. Vor dem ersten Lauf fehlen Tabellen, Spiele und der Instagram-Block (die Seite bricht deswegen nicht, sie zeigt Ersatzinhalte). ```bash cd ~/tsv08kulmbach-website php bin/matchcenter-sync.php # Spiele, Ergebnisse, Tabellen, Wappen php bin/instagram-sync.php # Posts und Bilder ``` **Der Instagram-Lauf ist der Wackelkandidat des ganzen Deploys.** Instagram sperrt Rechenzentrums-IPs; bisher lief der Scraper nur über einen privaten Anschluss. Schlägt er hier fehl, ist das kein Fehler im Deploy: dann bleibt der Weg, ihn von zu Hause laufen zu lassen und `data/instagram.json` samt Bildern hochzuladen, oder der Block wird redaktionell gepflegt. Die Website funktioniert in jedem Fall. ### C4 [SSH] `preflight` bis null Fehler ```bash php bin/preflight.php; echo "Exit: $?" ``` Ziel ist **Exit 0**. Warnungen darfst du bewerten, Fehler nicht. Erwartbare Warnungen hier: der Hinweis, dass `cron.key` leer ist (richtig so, siehe A1) und die Uploadgrenze, die erst ein echter Upload beweist (C6). ### C5 Durchklicken Die Seiten, die eigene Datenquellen haben: - [ ] Startseite: Banner-Slider, Termin-Karte im Hero, Instagram-Block - [ ] `/matchcenter`: Tabellen und nächste Spiele mit Wappen - [ ] `/termine`, `/news`, `/veranstaltungen` plus eine Detailseite - [ ] `/stadionzeitung` und die Ausgabe durchswipen — **alle Wappen und Instagram-Bilder da?** (Sie liegen im Repo, sollten also stimmen.) - [ ] `/stadionzeitung/aktuell` landet auf der neuesten Ausgabe - [ ] Die Druckfassung einer Ausgabe (`?druck=1`) - [ ] `/impressum`: Webadresse zeigt `www.tsv08kulmbach.de`, nicht die Subdomain ### C6 Dateiablage und Mailversand ```bash php bin/dateien-init.php # Jahr/Abteilung-Ordner, MUSS auf dem Server laufen php bin/smtp-test.php # nur Verbindung und Login, kein Versand ``` `dateien-init.php` gehört auf den Server, weil Umlaute per FTP zu Doppelordnern führen (macOS NFD gegen Linux NFC). - [ ] `/dateien` mit beiden Zugängen anmelden - [ ] Eine große Datei hochladen (>10 MB) — beweist die 250-MB-Grenze - [ ] Ein Formular abschicken (z. B. `/kontakt`) und die Mail im Postfach prüfen - [ ] `php bin/logs.php` zeigt keine Fehler --- ## Teil D — Cronjobs Erst jetzt, damit ab dem Umschalten schon frische Daten liegen. `SITE` und der PHP-Pfad kommen aus B1/B2. [KAS] → **Cronjobs** → eigener Cronjob (Shell), vier Einträge: ``` # Instagram 2x/Tag 17 6,18 * * * cd SITE && PHP bin/instagram-sync.php >> storage/logs/instagram.log 2>&1 # Matchcenter Grundtakt 2x/Tag 37 7,19 * * * cd SITE && PHP bin/matchcenter-sync.php >> storage/logs/matchcenter.log 2>&1 # Matchcenter am Spieltag halbstuendlich (Sa+So 13-21 Uhr) 7,37 13-21 * * 6,0 cd SITE && PHP bin/matchcenter-sync.php >> storage/logs/matchcenter.log 2>&1 # Logpflege sonntags nachts 23 3 * * 0 cd SITE && PHP bin/log-rotate.php > /dev/null 2>&1 ``` `SITE` und `PHP` durch die echten absoluten Pfade ersetzen, auf diesem Webspace also `cd /www/htdocs/w01ca75d/tsv08kulmbach-website && /usr/bin/php bin/…`. Die Minuten sind versetzt, damit sich zwei Läufe nicht überlappen. Alle Skripte sind CLI-only, sperren sich per Lock gegen Parallelläufe und lassen bei Fehlern den letzten guten Cache stehen — ein fehlgeschlagener Lauf schadet nie. Am Tag nach dem ersten Cron-Lauf einmal `php bin/logs.php` ansehen. Läuft nichts, ist fast immer der PHP-Pfad falsch oder relativ. --- ## Teil E — Umschalten auf die Hauptdomain Ab hier ist die Seite öffentlich. Alles davor muss sitzen. ### E1 Sicherheitsnetz Notiere, auf welches Verzeichnis `www.tsv08kulmbach.de` **jetzt** zeigt (die alte CMS-Seite). Das ist dein Rückweg. Verzeichnis und Datenbank der alten Seite bleiben unangetastet, nicht löschen. ### E2 [KAS] Domain umstellen **Domains** → `tsv08kulmbach.de` und `www.tsv08kulmbach.de` auf **`/tsv08kulmbach-website/public`** zeigen lassen. PHP-Version prüfen, SSL muss für beide aktiv sein. ### E3 Sofort danach prüfen - [ ] `https://www.tsv08kulmbach.de` lädt - [ ] `http://tsv08kulmbach.de` (ohne www, ohne s) landet per 301 auf `https://www.tsv08kulmbach.de` - [ ] `/sitemap.xml` und `/robots.txt` erreichbar - [ ] `curl -sI https://www.tsv08kulmbach.de | head` — Statuszeile 200, kein Redirect-Loop - [ ] Ein Formular live abschicken - [ ] **Einen gedruckten QR-Code mit dem Handy scannen.** Der Code der Stadionzeitung zeigt auf `www.tsv08kulmbach.de/stadionzeitung/aktuell`. Scannen ist die einzige echte Prüfung: die QR-Bibliothek arbeitet nicht reproduzierbar, ein Bildvergleich beweist nichts. ### E4 Verzeichnisschutz der Test-Subdomain lassen Nicht entfernen. Sonst steht die Seite zweimal im Netz und Google sieht doppelten Inhalt. Wenn du die Subdomain nicht mehr brauchst, in KAS löschen. --- ## Teil F — Danach ### F1 Der Update-Ablauf ab jetzt **Die Reihenfolge ist nicht verhandelbar**, weil `storage/dateien/uploads/` im Repo liegt und andere Menschen dort hochladen: ```bash # 1. [SSH] auf dem Server: neue Uploads sichern cd ~/tsv08kulmbach-website git add -A storage/dateien/uploads && git commit -m "Uploads vom Server" && git push # 2. [lokal] holen, dann entwickeln git pull # 3. [lokal] fertig, gepusht → [SSH] ausrollen cd ~/tsv08kulmbach-website && git pull && php bin/preflight.php ``` Nie in der anderen Reihenfolge. Details und Begründung in CLAUDE.md, Abschnitt Dateiablage. Ändert sich `composer.json`, gehört ein `composer install --no-dev --optimize-autoloader` dazu. Neue Formularfelder heißen: Datenschutzerklärung mitziehen. ### F2 Rückweg, falls etwas Grundlegendes bricht [KAS] Domain zurück auf das alte Verzeichnis aus E1. Nach ein paar Minuten ist die alte Seite wieder da. Kein Datenverlust, weil die neue Seite in einem eigenen Verzeichnis liegt und die alte Datenbank nicht anfasst. ### F3 Offen, nach dem Livegang - **301-Weiterleitungen der alten URLs.** Die neue Seite hat andere Slugs; alte Google-Treffer und fremde Links laufen ins Leere. Die alten Adressen stehen im DB-Dump unter `.context_db`. Aufwand lohnt für die paar wichtigen Seiten, nicht für alle. - **Google Search Console:** Property anlegen, `sitemap.xml` einreichen. - **Instagram-Sync** beobachten, siehe C3. - Nach dem Sportfest (ab 07.09.2026) fallen zwei Anzeigen per `bis`-Datum aus dem Bestand, die Stadionzeitung hat dann 38 Seiten statt 40 und ist nicht mehr durch 4 teilbar. `bin/stadionzeitung-add.php` warnt beim nächsten Lauf.