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
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
- Domains → Subdomain
neu.tsv08kulmbach.deanlegen. - Als Verzeichnis
/tsv08kulmbach-website/publiceintragen, nicht das Repo-Root. Damit liegenapp/,config/,data/,storage/undvendor/außerhalb des Docroots und sind grundsätzlich nicht per URL erreichbar. (Die Root-.htaccessist nur ein Notnagel für Hoster, die das nicht können.) - PHP-Version für die Subdomain auf 8.1 oder neuer stellen.
- SSL aktivieren (Let's Encrypt). Ohne Zertifikat läuft die Seite in eine
Endlosweiterleitung, denn
public/.htaccesserzwingt HTTPS. - 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.deträ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: noindexinpublic/.htaccess, host-bedingt nur für Namen, die mitneu.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,/veranstaltungenplus eine Detailseite/stadionzeitungund die Ausgabe durchswipen — alle Wappen und Instagram-Bilder da? (Sie liegen im Repo, sollten also stimmen.)/stadionzeitung/aktuelllandet auf der neuesten Ausgabe- Die Druckfassung einer Ausgabe (
?druck=1) /impressum: Webadresse zeigtwww.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).
/dateienmit 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.phpzeigt 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.phpwarnt, 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
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.delädthttp://tsv08kulmbach.de(ohne www, ohne s) landet per 301 aufhttps://www.tsv08kulmbach.de/sitemap.xmlund/robots.txterreichbarcurl -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 ausmain_navigationim 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.xmleinreichen. - 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.phpwarnt beim nächsten Lauf.