Files
fs 06a18a2d19 301-Weiterleitungen der alten CMS-Adressen
Der Aufnahmeantrag der Turnabteilung ging als alte URL in den Einladungen an
die neuen Familien raus und lief seit dem Umzug in einen 404, ebenso der Link
auf die Turn-Seite. Beide leiten jetzt weiter, dazu alle uebrigen Seiten der
alten Navigation unter /08/de/.

Die PDF-Regel matcht am Umlaut vorbei: der Dateiname kommt je nach Mailprogramm
als NFC, als NFD (so stand er in der alten Datenbank) oder roh als UTF-8 an.
Die Sammelregel wirft den alten Query-String weg, sonst haengt mod_rewrite
?group_filter_id=14 ans neue Ziel.

Erledigt damit den offenen Punkt F3 aus docs/deploy.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWoqDYuANFbJDGFQAgaRdT
2026-07-31 22:11:08 +02:00

16 KiB

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:

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?

git status --porcelain     # muss leer sein
git push origin main

Teil B — Server einrichten

B1 [SSH] Umgebung feststellen und notieren

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

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:

# [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: <host> 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]:

chmod 600 config/config.php     # enthält SMTP-Passwort und app_secret

B4 [SSH] Composer-Abhängigkeiten

cd /www/htdocs/w01ca75d/tsv08kulmbach-website
composer install --no-dev --optimize-autoloader

Kennt die Shell composer nicht, hol ihn dir lokal ins Verzeichnis:

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.

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:

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

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

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

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:

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

Domainstsv08kulmbach.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:

# 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. Erledigt am 31.07.2026: public/.htaccess, Block „Alte CMS-Adressen". Abgedeckt sind der PDF-Aufnahmeantrag der Turnabteilung (/userdata/01_Basis/Beitrittserkl…pdf, so in den Einladungen verschickt) und alle Seiten der alten Navigation unter /08/de/… (Zuordnung aus main_navigation im DB-Dump). Nicht dabei und bewusst nicht nachgezogen: die Bilder und das Hero-Video unter /userdata/04_Seiteninhalte/ — die liegen auf der neuen Seite in anderer Form, eine Weiterleitung von Bild-URLs hilft niemandem.
  • 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.