# 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 ssh-w01ca75d@SERVER # SERVER = Hostname oder IP aus dem KAS pwd # das Verzeichnis, in dem SSH startet which php; php -v # PHP muss >= 8.1 sein git --version ``` **Ergebnis vom 31.07.2026 auf diesem Webspace:** | | | |---|---| | Webspace-Wurzel | `/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` | **Falle: `~` ist hier `/`, nicht der Webspace.** Das SSH-Konto hat die Systemwurzel als Home-Verzeichnis, `cd ~` führt also aus dem Webspace heraus und `~/…`-Pfade existieren nicht. Alle Pfade in dieser Anleitung sind deshalb absolut — auch das `scp`-Ziel und die Cron-Zeilen. All-Inkl legt pro Domain einen Ordner unter `/www/htdocs//` an, dieses Verzeichnis 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 /www/htdocs/w01ca75d 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. # SERVER = derselbe Host wie beim ssh-Login (hier die Server-IP des Webspace). scp config/config.production.php \ ssh-w01ca75d@SERVER:/www/htdocs/w01ca75d/tsv08kulmbach-website/config/config.php ``` Platzhalter bewusst ohne spitze Klammern: `` liest die zsh als Eingabe-Umleitung und bricht mit „no such file or directory" ab. 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 /www/htdocs/w01ca75d/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. ### B4b [SSH] Git-Identität für den Server setzen Ohne sie bricht **jeder** Commit auf dem Server ab („Author identity unknown"), und ohne Commits funktioniert der Update-Ablauf aus F1 nicht. ```bash cd /www/htdocs/w01ca75d/tsv08kulmbach-website git config --local user.name "TSV 08 Server" git config --local user.email "fs@breadcrumb-solutions.de" ``` `--local` gilt nur für dieses Repo — die anderen Projekte auf dem Webspace bleiben unberührt. Der Name macht in `git log` auf einen Blick sichtbar, welche Commits vom Server stammen (Uploads, Sync-Stände) und welche vom Mac (Code, Inhalte). Wird ein Push mit `! [rejected] … (fetch first)` abgewiesen, hat der Mac inzwischen gepusht: `git pull --rebase origin main` und nochmal pushen. Rebase statt Merge, damit die Historie linear bleibt. ### 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** (Basic Auth) — empfohlen, aber kein Blocker. Er hält die Testseite komplett aus dem Netz. Ohne ihn ist das Risiko klein, weil jede Seite ein Canonical auf `www.tsv08kulmbach.de` trägt: Google konsolidiert darauf, doppelter Inhalt entsteht praktisch nicht. Es kann lediglich die Test-URL bis zum Umschalten in einem Suchergebnis auftauchen. Am 31.07.2026 bewusst weggelassen. Wer es ohne Basic Auth sauber will: `X-Robots-Tag: noindex` in `public/.htaccess`, host-bedingt nur für Namen, die mit `neu.` beginnen. ### 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 /www/htdocs/w01ca75d/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 **Die KAS-Cronjobs können nur URLs aufrufen, keine Shell-Befehle** (geprüft 31.07.2026: das Formular hat ein Feld „Protokoll / Pfad" mit `https://`, kein Kommandofeld). Der Takt läuft deshalb über `public/cron.php`. Aufrufer ist der Hoster selbst — der Schlüssel verlässt den Webspace nie, kein Dritter ist beteiligt. Voraussetzung: `cron.key` muss in `config/config.php` gesetzt sein (48 Zeichen, nicht `app_secret`). Steht er leer, antwortet der Endpoint mit 403 und **nichts** synct. `bin/preflight.php` prüft Länge und Verwechslung. [KAS] → **Cronjobs** → Anlegen, drei bis vier Einträge. `KEY` durch den Wert aus `config.php` ersetzen: | # | Pfad | Zeitpunkt | |---|---|---| | 1 | `www.tsv08kulmbach.de/cron.php?job=matchcenter&key=KEY` | stündlich, Minute 37 | | 2 | `www.tsv08kulmbach.de/cron.php?job=instagram&key=KEY` | täglich, 06:17 | | 3 | `www.tsv08kulmbach.de/cron.php?job=instagram&key=KEY` | täglich, 18:17 | | 4 | `www.tsv08kulmbach.de/cron.php?job=logrotate&key=KEY` | täglich, 03:23 | Vier Dinge dahinter sind Absicht: - **Matchcenter stündlich statt halbstündlich am Spieltag.** Das KAS kann Wochentage nicht mit einem Halbstunden-Takt kombinieren, wie es die ursprüngliche Crontab vorsah. Stündlich rund um die Uhr ist der brauchbare Ersatz: am Spieltag steht das Ergebnis spätestens eine Stunde später. Kostet 24 Läufe/Tag. Wer den Verkehr zum BFV klein halten will, nimmt zwei „täglich"-Jobs (07:37 und 19:37) und lebt damit, dass Samstagsergebnisse erst abends stehen. - **Instagram zwei Einträge**, weil „täglich" nur einen Zeitpunkt kennt. Bewusst nicht stündlich: Instagram sperrt auffällige Zugriffsmuster schneller als der BFV. - **Versetzte Minuten**, damit sich zwei Läufe nie überlappen (die Syncs sperren sich per Lock, sonst fällt einer aus). - **Das E-Mail-Feld im KAS bleibt leer.** Die Antwort ist immer leer (204 = gelaufen oder wegen Mindestabstand übersprungen, 403 = Schlüssel oder Job falsch), es gäbe also nichts zu mailen. Ob der Takt läuft, sagt `php bin/logs.php`; `php bin/preflight.php` warnt, wenn ein Datenstand zu alt wird. **Am Tag nach dem ersten Lauf** einmal `php bin/logs.php` ansehen. Läuft nichts, ist fast immer der Schlüssel in der URL falsch oder abgeschnitten. Gegenprobe von Hand: ```bash curl -s -o /dev/null -w '%{http_code}\n' \ 'https://www.tsv08kulmbach.de/cron.php?job=matchcenter&key=KEY' ``` 204 = angenommen, 403 = Schlüssel oder Job falsch. Direkt danach ein zweites Mal aufgerufen muss es ebenfalls 204 geben — dann hat der Mindestabstand gegriffen und der Lauf wurde übersprungen, was im Log als WARN steht. ## 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 /www/htdocs/w01ca75d/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 /www/htdocs/w01ca75d/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.