Files
tsv08kulmbach-website/docs/deploy.md
fs 8ed5707d24 Deploy-Anleitung: Git-Identität auf dem Server als eigener Schritt
Fehlte, und ohne sie bricht jeder Commit auf dem Server mit "Author identity
unknown" ab — womit der Update-Ablauf aus F1 nicht funktioniert. Dazu der
Umgang mit einem abgewiesenen Push (pull --rebase).

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

363 lines
14 KiB
Markdown

# 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/<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
```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: `<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]:
```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** 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 /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
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 /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.