# Umzug des Riccardo-Shops auf den Produktionsverbund

Stand 21.09.2026. Unser Shop: Shopware 6.7.14.1, 1.546 Artikel mit 3.249
Varianten, 21 Kategorien, 5.062 Medien, 26 CMS-Seiten, 22 eigene Plugins.

## Der Zielverbund

| Rolle | Host | Ausstattung | Zustand |
| --- | --- | --- | --- |
| Frontend 1 | WEB-SRV-1, 10.0.1.1 (intern; oeffentlich seit 23.09. nur fuer 94.31.93.15 per SSH) / 10.0.1.1 | 2 CPU, 3 GB, 75 GB | nginx, PHP 8.3.6, Worker + Planer des Altsystems, Laravel-App `stores`, `gutschein`; 24 GB frei (26 GB davon sind eine dev.log) |
| Frontend 2 | WEB-SRV-2, 46.225.226.85 / 10.0.1.2 | 2 CPU, 3 GB, 75 GB | nginx, PHP 8.3.6, keine Worker; 46 GB frei |
| Datenbank | DB-SRV-1/2/3, 10.0.3.1–3 | je 8 CPU, 15 GB, 301 GB | MariaDB 10.11.14, Galera, `cluster_size=3`, Primary/Synced |
| Cache | REDIS-SRV-1, 10.0.2.1 | 2 CPU, 3 GB | Redis 7.0.15, `maxmemory 2gb`, `volatile-lru`, Indizes 0–3 und 5 vom Altsystem belegt |
| Medien | Hetzner Object Storage, `nbg1` | – | Buckets `riccardo-sware-media` (89.122 Objekte, 47 GB), `riccardo-sware-private` |

TLS und Client-IP kommen von einem Load Balancer; die Frontends hören auf Port 80
und vertrauen `X-Forwarded-*` aus 10.0.0.0/8.

## Was geprüft ist

- **PHP 8.3.6 genügt**: Shopware 6.7.14.1 erlaubt 8.2–8.5, alle benötigten
  Erweiterungen sind auf beiden Frontends vorhanden, phpredis inklusive.
  Unser `composer.lock` erfüllt alle 29 Plattform-Anforderungen unter 8.3.
- **MariaDB 10.11.14 genügt** (6.7 verlangt ≥ 10.11), Zeichensatz utf8mb4.
- **Kein Node auf den Frontends.** Storefront und Administration sind deshalb
  im Entwicklungssystem gebaut und liegen fertig im Paket
  (`public/bundles/administration`, `public/theme`, Plugin-`dist`-Ordner).
- **Kein MariaDB-Client auf den Frontends.** Für den Import entweder
  `apt install mariadb-client` oder den Dump auf einem DB-Knoten einspielen.
- **APCu fehlt** – Shopware nutzt es für den Dateicache, zwingend ist es nicht.

## Was vorbereitet ist

| Datei | Zweck |
| --- | --- |
| `pruefe-ziel.sh` | Prüft Frontends, Cluster, Redis und Object Storage; ändert nichts |
| `shop-export.sh` | Baut das Umzugspaket aus dem Entwicklungssystem |
| `shop-import.sh` | Spielt es auf einem Frontend ein (Standard: nur Probelauf, `MODUS=uebernehmen` für echt) |
| `medien-hochladen.sh` | Lädt Medien und Vorschaubilder direkt in den Object Storage |
| `prod-config/storage.yaml` | Medien, Theme, Assets und Sitemap im Object Storage |
| `prod-config/redis.yaml` | Sessions, Cache, Warenkorb, Nummernkreise, Zähler über Redis (6.7-Schreibweise, Indizes 6–11) |
| `prod-config/env.prod.template` | Vorlage für `.env.local` mit Galera-Verteilung, Redis und S3 |
| `prod-config/prod-anpassungen.sql` | Domains umschreiben, Entwicklungsreste entfernen |
| `prod-config/nginx-riccardo.conf` | Site für unseren Shop, noindex bis zur Abnahme |
| `prod-config/fpm-riccardo.conf` | Eigener PHP-FPM-Pool mit eigener Prozessgrenze |
| `prod-config/rz-worker@.service`, `rz-scheduler.service` | Nachrichtenverarbeitung und geplante Aufgaben |

Das Paket vom 21.09.2026 liegt unter `/var/backups/riccardo-shop-umzug-20260921-1534/`:
Code 202 MB, Datenbank 12 MB gepackt, dazu die Liste der 22.099 Mediendateien
(10,1 GB: 5,0 GB Medien, 5,4 GB Vorschaubilder).

## Ablauf

1. **Platz schaffen** – `var/log/dev.log` des Altsystems abschneiden
   (26 GB → Frontend 1 hat danach rund 50 GB frei):
   `truncate -s 0 /var/www/shopware/var/log/dev.log`
2. **Datenbank und Benutzer anlegen** (auf einem Galera-Knoten, wirkt clusterweit):
   ```sql
   CREATE DATABASE riccardo_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
   CREATE USER 'riccardo_shop'@'10.0.1.%' IDENTIFIED BY '<passwort>';
   GRANT ALL PRIVILEGES ON riccardo_shop.* TO 'riccardo_shop'@'10.0.1.%';
   ```
3. **Bucket für unsere Medien** – eigener Bucket, damit der Bestand des
   Altsystems unberührt als Archiv stehen bleibt:
   `aws --endpoint-url https://nbg1.your-objectstorage.com s3 mb s3://riccardo-shop-media`
4. **Medien hochladen** (läuft vom Entwicklungssystem, nicht über die Frontends):
   `S3_KEY=… S3_SECRET=… ./medien-hochladen.sh riccardo-shop-media`
   Erst mit `--nur-pruefen` gegenrechnen.
5. **Paket auf das Frontend bringen** und `.env.local` aus der Vorlage ausfüllen.
6. **Probelauf**: `./shop-import.sh <paket> neu.riccardozigarette.com`
   (zeigt jeden Schritt, schreibt nichts).
7. **Einspielen**: `MODUS=uebernehmen ./shop-import.sh <paket> neu.riccardozigarette.com`
8. **Pool und Site aktivieren**:
   ```bash
   install -m 644 prod-config/fpm-riccardo.conf /etc/php/8.3/fpm/pool.d/riccardo.conf
   systemctl reload php8.3-fpm
   install -m 644 prod-config/nginx-riccardo.conf /etc/nginx/sites-available/riccardo.conf
   ln -sf /etc/nginx/sites-available/riccardo.conf /etc/nginx/sites-enabled/
   nginx -t && systemctl reload nginx
   systemctl enable --now rz-worker@1 rz-scheduler
   ```
   Der Planer läuft nur auf **einem** Frontend, die Worker können auf beiden laufen.
9. **Prüfen**: Startseite, ein Artikel mit Bild (kommt aus dem Object Storage),
   Suche, Warenkorb bis zur Zahlungsauswahl, Anmeldung im Admin, Anleitungen,
   Magazin, Darkmode. Danach `dal:refresh:index` vollständig laufen lassen.
10. **Zweites Frontend**: gleiche Schritte, aber `DATABASE_URL` auf 10.0.3.2
    (Repliken .1 und .3), kein Planer.

## Rückweg

Bis zum Umschalten der Live-Adresse bleibt das Altsystem unberührt: eigener
Ordner (`/var/www/shopware`), eigene Datenbank (`shopware`), eigener Bucket,
eigener FPM-Pool, eigene Redis-Indizes. Rückweg = unsere Site abschalten:
```bash
rm /etc/nginx/sites-enabled/riccardo.conf && systemctl reload nginx
systemctl disable --now rz-worker@1 rz-scheduler
```
Das Archiv des Altsystems liegt zusätzlich unter
`/var/backups/riccardo-prod-altsystem-20260921-1422/` (Management-Server) und als
Archivdatei auf Frontend 2.

## Durchgeführt am 21.09.2026

Der Shop läuft auf dem Verbund, DNS für `dev.riccardozigarette.com` zeigt auf den
Load Balancer 91.98.3.87 (Wildcard-Zertifikat `*.riccardozigarette.com`).

- **PHP 8.5.10** aus dem Repository `ppa:ondrej/php` auf beiden Frontends
  nachinstalliert, Standard-CLI bleibt 8.3 (für `stores`); unser Pool, unsere
  Worker und alle Konsolenaufrufe nutzen ausdrücklich `/usr/bin/php8.5`.
  Grund: drei Pakete unseres Locks verlangen PHP ≥ 8.4. `composer
  check-platform-reqs` prüft nur das Wurzelpaket und hatte das nicht gezeigt.
- Nachinstalliert, weil im Entwicklungssystem nie gebraucht:
  `league/flysystem-async-aws-s3` (S3) und `symfony/redis-messenger` (Redis-Transport).
- Datenbank `riccardo_shop` mit eigenem Benutzer im Galera-Cluster, Dump in 20 s
  eingespielt (292 Tabellen).
- Eigene Buckets `riccardo-shop-media` / `riccardo-shop-private`; 26.030 Objekte
  (10,9 GB) hochgeladen. Öffentliche Leserichtlinie wie beim Altsystem, dazu
  eine CORS-Regel – **Hetzner übernimmt CORS-Änderungen mit rund 20 Minuten
  Verzögerung**, das sieht zwischenzeitlich wie ein Fehler aus.
- `theme:compile` muss mit `--sync` laufen, sonst landen die statischen
  Theme-Bilder (`assets/img`) nicht im Bucket. Assets liegen unter der
  Theme-ID, CSS/JS unter einem anderen Hash – beides ist normal.
- Bildpfade in CMS-HTML-Blöcken waren relativ (`\/thumbnail\/…`, im JSON mit
  maskierten Schrägstrichen) und liefen nach dem Wechsel auf S3 ins Leere;
  in der Datenbank auf absolute Adressen umgeschrieben und
  `dev/demo-data/home.php` so geändert, dass es die Medien-Basisadresse
  aus der API nimmt.
- Altsystem abgeschaltet (nichts gelöscht): nginx-Sites `shopware.conf`,
  `shopware-api-primaer.conf` und `gutschein.conf` aus `sites-enabled` entfernt,
  Dienste `shopware-worker@1/@2` und `shopware-scheduler` gestoppt und
  deaktiviert. `stores.riccardozigarette.com` läuft weiter (PHP 8.3,
  `riccardo-worker.service`).
- `nginx-000-catchall.conf` fängt alle übrigen Adressen ab: `riccardozigarette.com`
  liefert eine leere Seite, Unterseiten 404. Das `/`-mit-200 ist Absicht – die
  Gesundheitsprüfung des Load Balancers trifft diesen Block, ein 404 würde die
  Frontends aus der Verteilung nehmen.

## Offene Punkte, die Entscheidungen brauchen

1. **Zieladresse**: Zwischenadresse (`neu.riccardozigarette.com`) oder direkt die
   Live-Domain? Die Vorlagen stehen auf Zwischenadresse mit `noindex`.
2. **Altsystem**: parallel laufen lassen oder beim Umschalten abschalten? Beide
   Shops gleichzeitig auf 3 GB RAM je Frontend ist eng – siehe FPM-Pool.
3. **Speicher der Datenbankknoten**: `innodb_buffer_pool_size` steht auf 128 MB
   bei 15 GB RAM. Für unseren Datenbestand sollten es 8–10 GB sein; das ist ein
   Eingriff in die Cluster-Konfiguration und braucht einen rollierenden Neustart.
4. **Vorschaubilder**: 5,4 GB mitschicken (schneller, mehr Speicher) oder auf dem
   Zielsystem neu erzeugen (spart Speicher, dauert auf 2 CPUs Stunden). Die
   Skripte laden aktuell beides hoch.
5. **E-Mail**: im Entwicklungssystem zeigt `MAILER_DSN` auf Mailpit. Für den
   Produktivbetrieb braucht es einen echten Postausgang.
6. **Lizenzen**: die 22 eigenen Plugins gehören uns; falls Fremd-Plugins des
   Altsystems übernommen werden sollen (Klarna, Mollie, VRPay, Pickware DHL),
   müssen deren Lizenzen auf den neuen Shop passen.

## Domainwechsel am 21.09.2026 (Nachtrag)

Entscheidung des Nutzers: `dev.riccardozigarette.com` bleibt beim
**Entwicklungsserver** (178.105.61.101), der Produktionsverbund läuft unter
**`shop.riccardo-zigarette.de`**. Diese Domain zeigt bereits auf den Load
Balancer 91.98.3.87, der ein passendes Let's-Encrypt-Zertifikat dafür hat
(gültig bis 07.12.2026).

Umgestellt wurde:
- nginx `server_name` auf beiden Frontends: `shop.riccardo-zigarette.de`
  (die alte Adresse läuft übergangsweise mit, bis das DNS zurückgestellt ist).
- `.env.local`: `APP_URL=https://shop.riccardo-zigarette.de`, `TRUSTED_HOSTS`
  als Ausdruck mit beiden Namen.
- `sales_channel_domain.url` auf die neue Adresse, Verkaufskanal von
  „Riccardo Dev" in „Riccardo Onlineshop" umbenannt.
- CORS am Bucket um die neue Domain erweitert.
- Sitemap neu erzeugt, Mailvorlagen mit `riccardo:mail:install` neu eingespielt
  (die Quelldateien nutzen den Platzhalter `%%DOMAIN%%`, deshalb genügt das).
- **Wichtig:** nach dem Zurückstellen des DNS `dev.riccardozigarette.com` aus
  `server_name` und `TRUSTED_HOSTS` entfernen, sonst beantwortet die Produktion
  weiterhin Anfragen für die Entwicklungsadresse.
- Der Cache muss nach einem Domainwechsel über `cache:pool:clear cache.object`
  geleert werden, nicht nur mit `cache:clear` – sonst antwortet der Shop mit
  „Sales Channel Not Found".

## Betrieb: was auf dem Verbund eingerichtet ist (21.09.2026)

| Bereich | Zustand |
| --- | --- |
| Datenbanksicherung | `prod-config/riccardo-db-backup.sh` auf **Frontend 1**, täglich 3:17 Uhr (`/etc/cron.d/riccardo-db-backup`). Dump über das Netz von DB-SRV-3, prüft vorher `wsrep_local_state_comment = Synced`, 14 Tage lokal unter `/var/backups/riccardo-db`, 30 Tage im Bucket `riccardo-shop-private/datenbank-sicherungen/`. Die Datenbankknoten selbst haben **keinen Internetzugang**, können also nicht in den Object Storage schreiben – deshalb läuft die Sicherung auf dem Frontend. Zugangsdaten in `/etc/riccardo-backup.env` (root, 600). |
| Logrotation | `prod-config/riccardo-logrotate.conf` → `/etc/logrotate.d/riccardo` auf beiden Frontends: täglich, 14 Stände, ab 200 MB, komprimiert, `copytruncate`. |
| Worker/Planer | je ein Worker pro Frontend (`rz-worker@1`), Planer nur auf Frontend 1. |
| MariaDB | Puffer 8 GB je Knoten, Logdatei 1 GB (`prod-config/mariadb-70-riccardo-tuning.cnf`). |

## Cache und Geschwindigkeit (21.09.2026 geprüft und eingerichtet)

Alles davon gilt **auf beiden Frontends** – dafür gibt es `auf-allen-frontends.sh`,
das einen Befehl auf FE1 und FE2 ausführt und meckert, wenn einer abweicht. Das
Altsystem hatte genau hier sein Problem: unterschiedliche Konfiguration je Knoten.

| Ebene | Zustand |
| --- | --- |
| Shopware-HTTP-Cache | aktiv, liegt in Redis (`cache.http`), Ausnahmen `logged-in` und `cart-filled` |
| Objekt-/System-Cache | Redis (`cache.app`, `cache.system`), Datenbank-Index 7 |
| Verzögerte Invalidierung | **auf Redis umgestellt** (vorher MySQL), Index 12; die geplante Aufgabe `shopware.invalidate_cache` arbeitet sie alle 300 s ab |
| Warenkorb, Nummernkreise, Zähler | Redis, Indizes 8/9/10 |
| OPcache | `99-riccardo-opcache.ini`: 256 MB, 32 MB Zeichenketten, 30.000 Dateien, `validate_timestamps=0`. **MUSS global liegen** – im Pool werden `memory_consumption` und `interned_strings_buffer` ignoriert, weil der FPM-Master den Speicher einmalig reserviert. |
| APCu | vorhanden (`php8.5-apcu`) |
| MariaDB | Puffer 8 GB je Knoten, Trefferquote 98,8 % |
| nginx-Kompression | `conf.d/kompression.conf`: HTML war schon komprimiert, jetzt auch JSON (Store-API, Chat), XML (Sitemap) und SVG. `gzip on` steht in der Hauptkonfiguration – nicht wiederholen, nginx lehnt Doppelangaben ab. |
| Theme/JS/CSS im Object Storage | **komprimiert und dauerhaft cachebar** über `assets-optimieren.sh`: all.css 657 KB → 99 KB, storefront.js 172 KB → 53 KB, `Cache-Control: public, max-age=31536000, immutable` |

**Pflichtschritte nach jedem Ausrollen** (sonst wirken Änderungen nicht oder die
Seite wird langsam):

```bash
# auf JEDEM Frontend, am einfachsten über auf-allen-frontends.sh
bin/console cache:clear
bin/console assets:install
bin/console theme:compile --sync      # ohne --sync fehlen die Theme-Bilder im Bucket
systemctl restart php8.5-fpm          # wegen opcache.validate_timestamps=0
/usr/local/sbin/assets-optimieren.sh  # einmal genügt, der Bucket ist gemeinsam
```

## Varnish (21.09.2026)

Aufbau: Load Balancer → **Varnish (Port 80, 512 MB, xkey)** → nginx (127.0.0.1:8080) → PHP 8.5.
Dateien: `prod-config/varnish-riccardo.vcl`, `prod-config/varnish-shopware.yaml`
(Shopware gibt den Seiten-Cache ab), systemd-Ergänzung unter
`/etc/systemd/system/varnish.service.d/riccardo.conf`.

Gemessen je Knoten, gleiche Bedingungen: **1.046 Aufrufe/s (18 ms)** über Varnish
gegen **241 Aufrufe/s (82 ms)** über Shopwares eigenen Cache und 7,5 Aufrufe/s
ohne jeden Seiten-Cache. Löschung wirkt knotenübergreifend (geprüft).

**Drei Fallen, die dabei zugeschlagen haben:**
1. `-F` im systemd-Aufruf fehlte → systemd beendet Varnish sofort. Und
   `enable --now` startet einen schon laufenden Dienst NICHT neu, er lief dann
   weiter mit der Standardkonfiguration.
2. Ohne Host-Prüfung cacht Varnish auch die **anderen Anwendungen** auf der
   Maschine (`stores` als Laravel-App). Die VCL cacht deshalb ausschließlich
   `shop.riccardo-zigarette.de`.
3. **Die echte Besucher-IP ging verloren**: Varnish verbindet über 127.0.0.1,
   und nginx vertraute nur `10.0.0.0/8`. Folge: Shopware sah 127.0.0.1, die
   Wartungs-Freigabeliste und alle IP-Sperren waren wirkungslos. Fix:
   `set_real_ip_from 127.0.0.1;` in allen vhosts. Prüfen mit
   `curl -H "X-Forwarded-For: <freigegebene IP>"` gegen Port 80 – muss 200
   liefern, eine fremde IP 307.

## Wiederherstellung (Stand 21.09.2026)

| Baustein | Wo | Wann |
| --- | --- | --- |
| Datenbank-Dump | `/var/backups/riccardo-db` auf Frontend 1 **und** Bucket `riccardo-shop-private/datenbank-sicherungen/` | täglich 3:17 |
| Binärprotokolle | `/var/backups/riccardo-binlog` auf Frontend 1 **und** Bucket `riccardo-shop-private/binlog/` | stündlich zur Minute 11 |
| Medien | Bucket `riccardo-shop-media` (einzige Kopie – noch offen) | laufend |

Damit ist ein Stand auf die **Stunde genau** wiederherstellbar: Dump einspielen,
dann die Protokolle ab dem Dump-Zeitpunkt nachfahren:

```bash
zcat riccardo_shop-<stempel>.sql.gz | mariadb
mariadb-binlog --start-datetime="<Zeitpunkt des Dumps>" --stop-datetime="<Zielzeitpunkt>" \
    mysql-bin.0000XX mysql-bin.0000YY | mariadb
```

**Einstellungen** (`prod-config/mariadb-71-riccardo-binlog.cnf`, je Knoten mit
eigener `server_id` ausgerollt): `log_bin`, `binlog_format=ROW`,
`binlog_row_image=FULL`, 7 Tage Aufbewahrung, 256 MB je Datei, `sync_binlog=0`.

**Zwei Fallen bei Galera**, die beide zugeschlagen hätten:
1. Alle drei Knoten hatten `server_id = 1`. Mit Binlog muss die eindeutig sein,
   sonst sind die Protokolle nicht auseinanderzuhalten. Jetzt 1, 2, 3.
2. `log_slave_updates` war aus. In Galera kommen fremde Schreibvorgänge über die
   Replikation an – ohne diese Einstellung landen sie **nicht** im lokalen
   Binlog, und das Protokoll wäre für eine Wiederherstellung wertlos. Geprüft:
   ein Schreibvorgang auf Knoten 1 erscheint im Binlog von Knoten 3 (`server id 1`).

Die Protokolle holt `prod-config/riccardo-binlog-archiv.sh` stündlich über das
Netz (`mariadb-binlog --read-from-remote-server`) von Knoten 3 und legt sie im
privaten Bucket ab; dafür gibt es den Benutzer `riccardo_binlog` mit nur
`REPLICATION SLAVE, BINLOG MONITOR`.

## Was noch offen ist

1. **Postausgang**: `MAILER_DSN=null://null` – der Shop verschickt keine Mails.
2. ~~Kein Binlog~~ **erledigt am 21.09.2026** – siehe Abschnitt „Wiederherstellung".
3. **Medien haben keine zweite Kopie**: der Bucket ist die einzige Ablage, ohne
   Versionierung. Eine gelöschte Datei ist weg. Wöchentliche Spiegelung in einen
   zweiten Bucket wäre die einfachste Absicherung.
4. **Altsystem-Reste**: 26 GB `dev.log` auf Frontend 1 und 19 GB auf Frontend 2,
   dazu Code unter `/var/www/shopware`, die Datenbank `shopware` (622 MB) und der
   Bucket `riccardo-sware-media` (47 GB, kostet Speicher). Alles archiviert unter
   `/var/backups/riccardo-prod-altsystem-20260921-1422/`, kann nach einer Schonfrist
   weg.
5. **noindex**: unsere nginx-Site setzt `X-Robots-Tag: noindex, nofollow`. Vor dem
   Start muss das raus, sonst wird der Shop nie indexiert.
6. **CORS am Bucket** steht auf `*`. Für einen öffentlichen Medien-Bucket
   vertretbar, kann aber auf die eigenen Domains eingeengt werden.
7. ~~Kein Weg zum Ausrollen~~ **erledigt am 22.09.2026** – `plugin-ausrollen.sh`
   baut hier, schnürt ein Paket und spielt es auf beiden Knoten ein. Offen bleibt,
   dass die Knotenliste darin fest eingetragen ist (siehe „Weitere Frontends“).
8. ~~Reverse-Proxy-Cache nicht eingerichtet~~ **erledigt am 21.09.2026** – siehe
   Abschnitt „Varnish“. Gemessen 1.046 Aufrufe/s statt 241 über Shopwares
   eigenen Cache.
9. **OpenSearch** ist nicht angebunden (die Knoten haben keinen Suchdienst). Bei
   1.546 Artikeln reicht die Datenbanksuche; ab ~10.000 Artikeln mit vielen
   Filtern lohnt es sich.
10. **Überwachung** fehlt ganz: niemand merkt, wenn ein Worker stirbt, die Platte
   volläuft oder ein Galera-Knoten aussteigt.

## Plugin auf die Frontends ausrollen (22.09.2026)

```bash
bin/build-administration.sh                       # nur wenn das Plugin einen Admin-Teil hat
dev/deploy/plugin-ausrollen.sh <zugang> RiccardoSearch
```

Das Skript überträgt das Plugin auf beide Frontends, aktualisiert es einmal
(die Datenbank ist gemeinsam), lädt die Assets in den Object Storage, setzt
dort Komprimierung und Gültigkeit und leert den Zwischenspeicher auf beiden
Knoten.

Zwei Dinge, die beim ersten Mal Zeit gekostet haben:

* **Auf den Frontends ist kein Node installiert** und das soll auch so
  bleiben: die Maschinen haben 3,8 GB RAM, der Admin-Build allein braucht über
  2 GB. Gebaut wird auf dem Entwicklungsserver, ausgeliefert wird das fertige
  `src/Resources/public/`. Das Skript bricht ab, wenn ein Plugin einen
  Admin-Teil hat, der nicht gebaut ist – sonst ginge stillschweigend ein alter
  Stand raus.
* **Nach dem Plugin-Update reicht `cache:clear` nicht.** PHP-FPM hält den
  alten kompilierten Container, und das Entitäten-Schema für die Verwaltung
  liegt im Redis. Fehlt eines von beidem, meldet der Admin „No definition
  found for entity type …“, obwohl die API die Entität längst kennt – das
  sieht aus, als wäre das Plugin gar nicht angekommen. Das Skript macht
  deshalb zusätzlich `cache:pool:clear cache.object` und
  `systemctl reload php8.5-fpm` auf beiden Knoten.
* **Auf dem Entwicklungsserver muss OpenSearch für den Build kurz aus.**
  Der Dienst hält 1,2 GB, der Build braucht 2,3 GB, zusammen reicht es nicht
  und der Kernel schießt den Build ab („Killed“, ohne weitere Meldung):

  ```bash
  systemctl stop opensearch && bin/build-administration.sh && systemctl start opensearch
  ```

  Nebenbei: `/tmp` liegt auf diesem Server im RAM. Große Dateien dort (ein
  vergessenes Installationsarchiv, heruntergeladene Browser) kosten direkt
  Arbeitsspeicher und haben den Build ebenfalls scheitern lassen.

## Sicherheit auf der Produktion (23.09.2026)

Anlass: das Technik-Audit vom 21.09. bewertete die Sicherheit mit 85/100 –
gemessen auf dem Entwicklungsserver. Auf der Produktion fehlten die
Kopfzeilen in der Antwort, und beim Nachsehen kamen zwei Dinge heraus, die
schwerer wiegen als jede Kopfzeile.

### Was angegriffen wurde

`journalctl -u ssh` zählte in 24 Stunden **23.021 fehlgeschlagene
Anmeldeversuche auf Frontend 1 und 8.938 auf Frontend 2** – bei
`PermitRootLogin yes`, `PasswordAuthentication yes`, ohne Firewall und ohne
fail2ban. Beide Frontends hängen mit öffentlicher IP am Netz.

* **fail2ban** ist jetzt auf beiden Knoten aktiv (`prod-config/fail2ban-riccardo.conf`,
  5 Fehlversuche in 10 Minuten → 1 Stunde Sperre). Innerhalb von Sekunden
  waren die ersten Adressen gesperrt.
* **Geplant**: der SSH-Port wird nach Abschluss der Entwicklung geschlossen
  und nur für Wartungsarbeiten geöffnet. Bis dahin ist fail2ban die Bremse.
* **apt-cacher-ng** lauschte auf beiden Knoten öffentlich auf Port 3142 –
  ein offener Paket-Proxy. Jetzt auf `127.0.0.1` und die interne Adresse
  gebunden.

### Kopfzeilen

`prod-config/nginx-sicherheits-header.conf` → `/etc/nginx/snippets/riccardo-sicherheit.conf`,
eingebunden im Server-Block **und** im Block für statische Dateien.

Zwei Fallstricke, die dabei aufgefallen sind:

* **nginx vererbt `add_header` nicht** in einen `location`-Block, der selbst
  eines setzt. Der Block für statische Dateien setzt `Cache-Control` – ohne
  erneutes Einbinden hätte dort keine einzige Sicherheits-Kopfzeile gestanden.
* **Shopware setzt vier dieser Kopfzeilen selbst** (`Framework/Routing/CoreSubscriber.php`:
  HSTS, X-Frame-Options, nosniff, Referrer-Policy). Es war also nie der
  Webserver, der sie auf dem Entwicklungssystem geliefert hat. Ohne
  Gegenmaßnahme kamen sie doppelt an, bei `X-Frame-Options` sogar mit
  widersprüchlichen Werten (`deny` aus der Anwendung, `SAMEORIGIN` aus nginx)
  – das dürfen Browser als ungültig verwerfen. Der Server-Block blendet die
  Kopfzeilen der Anwendung deshalb mit `fastcgi_hide_header` aus; einzige
  Quelle ist die Snippet-Datei, mit Shopwares eigenem Wert `deny`.
* Ebenfalls ausgeblendet: `sw-maintenance-allowlist` und
  `sw-maintenance-whitelist`. Shopware nennt darin die freigeschalteten
  IP-Adressen – das stand bis dahin in jeder Wartungsantwort.
* `server_tokens off` (nginx-Version) und `expose_php = Off`.

Nachgeprüft auf beiden Knoten, dynamisch und statisch, sowie von außen:
jede Kopfzeile genau einmal, gleicher Wert.

### Offen

* **Content-Security-Policy** – erst nach dem Go-Live im Report-Only-Modus,
  wegen PayPal, Google Tag Manager und eigenen Skripten.
* **Anmeldung per Schlüssel statt Passwort**, sobald der SSH-Port dauerhaft
  geschlossen ist.

## Cloud-Firewall der Webserver (23.09.2026)

`dev/deploy/hetzner-firewall.sh` – zeigt, setzt und nimmt zurück.

```bash
export HCLOUD_TOKEN=…            # oder ~/.hetzner-token
dev/deploy/hetzner-firewall.sh zeigen
dev/deploy/hetzner-firewall.sh setzen 178.105.61.101,<Büro-IP>
dev/deploy/hetzner-firewall.sh zurueck /tmp/firewall-….json
```

### Warum 80 und 443 wegfallen können

Der Load Balancer (LB-SRV-1, 91.98.3.87) spricht die Webserver **über das
interne Netz** an – die Ziele stehen auf `use_private_ip`, die
Gesundheitsprüfung läuft auf Port 80 ebenfalls intern. Cloud-Firewalls filtern
internen Verkehr nicht.

Die TLS-Zertifikate sind **Hetzner-verwaltet** und werden am Load Balancer
erneuert, nicht auf den Servern. Es braucht also auch kein öffentliches Port 80
für eine ACME-Prüfung.

Ergebnis: Die Webserver sind aus dem Internet nur noch über SSH erreichbar,
der Shop läuft unverändert über den Load Balancer. Gemessen am 23.09.2026 war
Port 443 auf den Servern ohnehin geschlossen – nginx lauscht dort nicht, die
Regel war von Anfang an wirkungslos.

### Umgesetzt am 23.09.2026

Die Webserver-Firewall hat jetzt **eine einzige Regel**: SSH von
`94.31.93.15/32`. Ports 80 und 443 sind weg.

Nachgeprüft unmittelbar danach:

* Shop über den Load Balancer erreichbar (Start-, Kategorie- und Artikelseite)
* `46.225.106.28` und `46.225.226.85`: Port 80 **zu**, Port 22 **zu**
* Zugang von diesem Server über das interne Netz (`10.0.1.1`, `10.0.1.2`) läuft

**Folge für die Deploy-Skripte:** sie sprechen die Frontends jetzt über
`10.0.1.1` und `10.0.1.2` an. Der frühere Umweg von Frontend 2 über Frontend 1
entfällt – beide liegen im selben internen Netz wie dieser Server (10.0.0.6).
Wer von außen arbeiten will, braucht seine IP in der Firewall.

Rückweg, falls nötig:

```bash
dev/deploy/hetzner-firewall.sh zurueck /tmp/firewall-10593979-<zeitstempel>.json
```

Für die anderen Firewalls dieselbe Mechanik mit anderer Kennung:

| Firewall | Id |
|---|---|
| Webserver-Filter-Inbound | 10593979 |
| MGMT-Filter-Inbound (Entwicklung) | 11668618 |
| Roxana-Filter-Inbound | 11668657 |

```bash
FIREWALL_ID=11668618 dev/deploy/hetzner-firewall.sh zeigen
```

Bewusst getrennt statt einer gemeinsamen Firewall: die drei Systeme haben
unterschiedliche Aufgaben, und eine Änderung an einer soll die anderen nicht
mitnehmen. Die Webserver kommen ohne offene Webports aus, die beiden anderen
nicht – das in eine Regelmenge zu pressen hieße, den Webservern Ports offen
zu lassen, die sie nicht brauchen.

### Stand und offene Punkte

| | |
|---|---|
| MGMT-SRV-01 (Entwicklung) | Firewall `MGMT-Filter-Inbound` (Id 11668618) seit 23.09.: 80 und 443 offen (neun Domains, TLS wird hier selbst terminiert, Let's Encrypt braucht Port 80), SSH nur von `94.31.93.15/32`, ICMP für Diagnose. Geprüft von Frontend 1 aus: 22 zu, 80 und 443 offen, dev-Shop antwortet. |
| Roxana | Firewall `Roxana-Filter-Inbound` (Id 11668657) seit 23.09.: 80 und 443 offen (Caddy, leitet auf HTTPS um und erneuert darüber seine Zertifikate), SSH nur von `94.31.93.15/32`, ICMP für Diagnose. Geprüft von Frontend 1 aus: 22 zu, 80 und 443 offen. |
| DB-SRV-1/2/3, REDIS-SRV-1 | ohne öffentliche IP, nicht erreichbar |

**Nicht in diesem Zuständigkeitsbereich, aber festgehalten:** auf allen sechs
JTL-Servern ist **RDP (3389) aus dem ganzen Internet erreichbar**, dazu auf
zweien die Wawi-API (5883). In derselben Firewall ist SSH bereits auf eine
feste Adresse beschränkt – bei RDP wurde es offenbar nur nicht nachgezogen.
Offenes RDP ist der häufigste Einstiegsweg für Verschlüsselungstrojaner.

## Uebertrag am 25.09.2026 (Dev-Stand vollstaendig auf die Produktion)

Der zweite Uebertrag, diesmal auf einen **laufenden** Shop. Vier Dinge sind
dabei aufgefallen, die beim ersten Mal nicht auffallen konnten, weil das Ziel
leer war:

| Stolperstein | Behebung |
|---|---|
| `.env.local` lag im Code-Archiv | Aus dem Export ausgeschlossen, zweiter Riegel beim Auspacken. Ueber einen laufenden Shop ausgepackt haette es ihn auf die Entwicklungsdatenbank umgebogen. |
| `mv` legte den neuen Baum IN den alten | Es wird immer erst daneben ausgepackt und dann mit `rsync` abgeglichen; Konfiguration, `var/`, `vendor/` und Medien bleiben ausgenommen. |
| `composer` und `bin/console` liefen mit PHP 8.3 | Die Frontends tragen 8.3 fuer Altanwendungen und 8.5 fuer den Shop. Das Skript legt `php8.5` ausdruecklich fest, sonst bricht composer an der `composer.lock` ab. |
| `mariadb` ohne Zugangsdaten | Auf den Frontends laeuft kein Datenbankdienst. Der Zugang wird jetzt aus der `.env.local` des Zielsystems abgeleitet und ueber eine Optionsdatei uebergeben, nie auf der Befehlszeile. |

**Die Medien reisen NICHT im Paket mit.** Das Paket enthaelt nur eine Liste;
hochgeladen wird getrennt und direkt in den Object Storage:

```bash
eval "$(ssh <frontend> 'grep -E "^S3_(KEY|SECRET|ENDPOINT|REGION|BUCKET_PUBLIC)=" /var/www/riccardo/.env.local | sed "s/^/export /"')"
dev/deploy/medien-hochladen.sh "$S3_BUCKET_PUBLIC" --nur-pruefen   # zaehlen
dev/deploy/medien-hochladen.sh "$S3_BUCKET_PUBLIC"                 # hochladen
```

Wird das vergessen, liefert der Shop lauter `404` aus dem Bucket: die
Datenbank kennt die neuen Medien, der Speicher nicht. Am 25.09. waren das
29.579 Dateien (1,7 GB) - das Entwicklungssystem legt Medien lokal ab, der
Verbund im Object Storage.

**Zweiter Knoten:** `OHNE_DB=1` setzen. Galera ist gemeinsam; ein zweiter
Import wuerde den Stand des ersten Knotens ueberschreiben.

```bash
OHNE_DB=1 MODUS=uebernehmen /root/deploy/shop-import.sh <paket> <domain>
```

## Zugangsdaten: wo sie liegen (Stand 25.09.2026)

| | |
|---|---|
| Hetzner-API-Token | `/root/.hetzner-token` auf MGMT-SRV-01, Rechte 600, root. `varnish-knoten.sh` und `hetzner-firewall.sh` lesen ihn von dort, alternativ aus `HCLOUD_TOKEN`. |
| SSH-Schlüssel | `/root/.ssh/riccardo-prod` (privat, 600) und `.pub`. Fingerabdruck `SHA256:NgyWnezy1oXLT4UBMsyPlFdYAYtZkva8sZUgaCNgmno`. Ohne Passphrase, weil die Ausroll-Skripte unbeaufsichtigt laufen – der private Schlüssel auf MGMT-SRV-01 **ist** der Zugang. Seit 25.09.2026 auf allen sechs Knoten hinterlegt. |
| root-Passwörter (Zugangsordner) | `/root/.riccardo-prod/`, Rechte 700, Dateien `fe1 fe2 db1 db2 db3 redis` (je 600) und `hosts` mit der Zuordnung Name → interne Adresse. Von den Skripten über `sshpass -f` gelesen. **Sollen weg**, sobald die Skripte auf den Schlüssel umgestellt sind. |

Beides gehört **nicht** ins Repository. Der Token lag früher in einem
Sitzungsordner unter `/tmp` und war nach dessen Aufräumen weg; deshalb jetzt
im Home von root, das Neustarts überlebt.

### Schlüssel ausgerollt am 25.09.2026

Auf allen sechs Knoten in `~/.ssh/authorized_keys` eingetragen, mit
Herkunftsbeschränkung, damit er nur von MGMT-SRV-01 aus gilt:

```
from="10.0.0.6" ssh-ed25519 AAAA… riccardo-deploy@MGMT-SRV-01 25.09.2026
```

| Knoten | vorher | jetzt |
|---|---|---|
| WEB-SRV-1 (10.0.1.1) | **keine `authorized_keys`** | 1 Schlüssel |
| WEB-SRV-2 (10.0.1.2) | 1 Schlüssel (`info@jdtec.eu`, RSA 4096) | 2 |
| DB-SRV-1/2/3, REDIS-SRV-1 | je 1 Schlüssel (derselbe) | je 2 |

WEB-SRV-1 war der einzige Knoten ganz ohne hinterlegten Schlüssel – dort ging
bisher nur das Passwort. Geprüft: Anmeldung auf allen sechs Knoten mit
`PasswordAuthentication=no` und `PreferredAuthentications=publickey`, also
ausschließlich über den Schlüssel.

Danach sind zwei Dinge fällig, die heute unschön sind:

* Die Skripte auf `ssh -i /root/.ssh/riccardo-prod` umstellen und `sshpass`
  samt Passwortdateien loswerden.
* `PasswordAuthentication no` in der `sshd_config` – steht am 25.09.2026 auf
  allen sechs Knoten noch auf `yes`. Solange Passwörter erlaubt sind, nützt
  der Schlüssel nur der Bequemlichkeit, nicht der Sicherheit.

Die Skripte setzen außerdem `StrictHostKeyChecking=no` und verwerfen die
`known_hosts`. Im internen Netz vertretbar, aber beim Umstellen wäre der
richtige Moment, die Wirtsschlüssel einmal festzuschreiben.

Prüfen, ob der Token lebt (ändert nichts):

```bash
curl -sS -H "Authorization: Bearer $(cat /root/.hetzner-token)" \
     'https://api.hetzner.cloud/v1/servers?label_selector=Webserver' | head -c 200
```

## Weitere Frontends hinzunehmen (Stand 23.09.2026)

Geprüft, was einem dritten Knoten im Weg stünde.

### Was bereits passt

| | |
|---|---|
| Sitzungen, Warenkorb, Nummernkreise, Zähler | Redis – die Frontends sind zustandslos, keine klebrigen Sitzungen nötig |
| Medien, Theme, Bundle-Dateien | Object Storage – nichts zu kopieren |
| Datenbankrechte | `riccardo_shop@10.0.1.%` – **jeder** Knoten im Frontend-Netz darf, keine Anpassung nötig |
| Verbindungen | `max_connections` 151 je Knoten, Spitzenwert seit dem Start: 10. Bei `pm.max_children = 12` je Frontend ist das weit weg von der Grenze |
| Firewall | Label „Webserver" – ein neuer Knoten bekommt sie automatisch |
| Load Balancer | seit 23.09. Label-Auswahl statt fester Ziele – hängt sich selbst ein (lb11, bis 25 Ziele) |
| Zeitplaner | läuft **nur** auf Frontend 1; Frontend 2 hat ihn aus. Das muss so bleiben – er darf sich nicht vervielfachen |
| Worker | auf beiden, teilen sich die Warteschlange über Redis – beliebig vermehrbar |

### Aufnehmen mit einem Befehl (seit 25.09.2026)

```bash
dev/deploy/frontend-aufnehmen.sh pruefen                  # Vorbedingungen, ändert nichts
dev/deploy/frontend-aufnehmen.sh anlegen --trocken        # zeigt das Vorhaben
dev/deploy/frontend-aufnehmen.sh anlegen                  # fragt vor dem Anlegen nach
```

**Ein neuer Knoten wird von einem laufenden Frontend GEKLONT, nicht aus dem
Repository gebaut.** Auf den Frontends liegt mehr als der Quelltext: die
installierte Shopware-Fassung mit ihren Abhängigkeiten, die `.env.local` mit
den Zugängen zu Datenbank, Redis und Object Storage, die Einstellungen von
nginx, PHP-FPM und Varnish. Ein aus dem Repository gebauter Knoten wäre ein
anderer Server, der nur so aussieht wie die anderen.

Der Ablauf und warum er so herum läuft:

| Schritt | warum |
|---|---|
| Gleichheit der vorhandenen Knoten prüfen | Ein Klon vererbt den Rückstand des Servers, von dem er stammt |
| Vorlage ist ein Knoten **ohne** Zeitplaner | Sonst brächte der Klon einen zweiten mit, und alle geplanten Aufgaben liefen doppelt |
| Schnappschuss im laufenden Betrieb | Der Zustand liegt in Datenbank, Redis und Object Storage; Herunterfahren nähme den Knoten für Minuten aus dem Load Balancer |
| Rechnername, Maschinenkennung, SSH-Wirtsschlüssel neu | Kommen alle aus dem Klon und wären sonst doppelt vergeben |
| Eigener Galera-Knoten zum Schreiben | Knoten *n* schreibt auf `10.0.3.n` und liest von den anderen |
| Varnish aufwärmen | Ein frischer Knoten hat leeren Cache und liefert bei `least_connections` sofort die schlechtesten Antwortzeiten |
| **Label „Webserver" zuletzt** | Damit hängen sich Firewall, Load Balancer und Varnish-Löschliste von selbst ein – aber eben erst, wenn der Knoten wirklich liefert |

Die Prüfung vergleicht nur **Dateien**, keine Verzeichnisse: deren gemeldete
Größe ist die Blockbelegung und weicht ab, sobald in einem Ordner einmal mehr
Einträge lagen. Beim ersten Versuch sah das nach einem echten Unterschied in
`vendor` aus – tatsächlich waren es vier Verzeichnis-Inodes.

Stand 25.09.2026: 39.709 Dateien auf beiden Knoten, genau eine weicht ab
(`config/packages/varnish.yaml`, und die nur in einer Kommentarzeile – sie
wird erzeugt und ist deshalb von der Prüfung ausgenommen).

### Übersicht und Knopf im Shop-Admin (25.09.2026)

**Einstellungen › Systemzustand** zeigt jeden Knoten mit Last je Kern,
PHP-Arbeitern, Speicher, Platte und der Zeit seit der letzten Meldung, dazu
eine Empfehlung und einen Knopf „Knoten hinzunehmen".

Woher die Zahlen kommen: **Jeder Knoten meldet sich selbst.** Beim Beenden
einer Anfrage, höchstens einmal je Minute, schreibt er seine Zahlen in
`riccardo_verbund_knoten`. Kein SSH, kein Token, kein Zeitplaner (der läuft
ja nur auf einem Knoten) – und ein Knoten, der nichts mehr meldet, fällt
genau dadurch auf.

**Der Admin spricht nicht mit Hetzner.** Läge auf dem Shop ein Token mit
Schreibrechten, könnte jeder, der in den Admin kommt, Server anlegen und
löschen. Der Knopf vermerkt deshalb nur einen Auftrag in
`riccardo_verbund_auftrag`. Abgeholt und ausgeführt wird er vom
Verwaltungsserver:

```bash
dev/deploy/verbund-agent.sh einmal      # ein Durchlauf
dev/deploy/verbund-agent.sh schleife    # alle zwei Minuten, für die Hand
```

Als Dienst (Vorlagen unter `prod-config/`, **noch nicht eingeschaltet**):

```bash
cp dev/deploy/prod-config/riccardo-verbund-agent.{service,timer} /etc/systemd/system/
systemctl daemon-reload && systemctl enable --now riccardo-verbund-agent.timer
```

Solange der Timer aus ist, bleibt ein angeforderter Auftrag einfach stehen –
sichtbar im Admin – bis jemand `verbund-agent.sh einmal` aufruft. Ist er an,
legt ein Klick im Admin tatsächlich einen kostenpflichtigen Server an.

Die Empfehlung urteilt getrennt über Maschine und Arbeiter: Sind die
FPM-Arbeiter am Anschlag, die CPUs aber gelangweilt, rät sie ausdrücklich
**nicht** zu einem Knoten, sondern dazu, `pm.max_children` zu erhöhen.

### Was fehlt

**Die Varnish-Löschliste** (`config/packages/varnish.yaml`) wird seit dem
23.09.2026 aus dem Hetzner-Label „Webserver" erzeugt:

```bash
dev/deploy/varnish-knoten.sh pruefen <zugang>   # nur vergleichen
dev/deploy/varnish-knoten.sh setzen  <zugang>   # auf allen Knoten schreiben
```

`plugin-ausrollen.sh` prüft das vor jedem Ausrollen und warnt bei Abweichung.

Belegt, dass die Auffächerung funktioniert: `cache:clear:http` auf Frontend 1
ausgeführt, danach war `MAIN.bans_added` auf **beiden** Knoten um eins höher.

Zwei Dinge, die beim Bauen schiefgingen und deshalb im Skript abgesichert sind:

* **Die Liste lässt sich nicht über eine Umgebungsvariable füttern.** Symfony
  lehnt das ab: „A dynamic value is not compatible with a PrototypedArrayNode".
  Deshalb wird die Datei erzeugt, nicht parametrisiert.
* **Die YAML per `$(printf …)` in der Shell zusammenzubauen, ging schief** –
  die Befehlssubstitution schluckt den letzten Zeilenumbruch, der nächste
  Schlüssel landete hinter dem letzten Listeneintrag. Ergebnis: ungültige
  YAML, und Shopware startete auf dem Knoten nicht mehr. Das Skript erzeugt
  die Datei jetzt in Python, prüft sie vor dem Verteilen auf Gültigkeit,
  sichert die alte Fassung und spielt sie automatisch zurück, wenn
  `cache:clear` fehlschlägt.

**Datenbankziel je Knoten:** Frontend 1 verbindet auf `10.0.3.1`, Frontend 2
auf `10.0.3.2` – bewusst verteilt. Ein aus einem Schnappschuss geklonter
Knoten erbt den Wert des Ursprungs; für einen dritten wäre `10.0.3.3` richtig.

**Kalter Varnish:** Ein frischer Knoten hat einen leeren Cache. Bei
`least_connections` bekommt er sofort Verkehr und liefert lauter
Cache-Fehlschläge – also die schlechtesten Antwortzeiten genau dann, wenn man
Last abfedern wollte. Deshalb: erst aufwärmen, dann an den Load Balancer.

### Kapazität: mehr Arbeiter statt mehr Knoten

`pm.max_children` steht auf **12** je Frontend. Bei 97 % Varnish-Trefferquote
sieht PHP nur einen Bruchteil des Verkehrs, aber unter echter Last ist das die
Obergrenze – und sie zu erhöhen ist billiger und schneller als ein neuer
Server. Vor dem Hinzunehmen eines Knotens lohnt der Blick, ob die vorhandenen
überhaupt ausgelastet sind.

### Nebenbefund: Altbestand in der Datenbank

Die Datenbank `shopware` (331 Tabellen, 622 MB) ist die alte Produktions-
datenbank, beim Umzug mitgekommen. **Nichts greift darauf zu** – der neue Shop
nutzt `riccardo_shop`, die Filialseite `stores_website`. Sie liegt zusätzlich
im Altsystem-Archiv. Löschen ist eine Entscheidung über Aufbewahrung, keine
technische.

Der Benutzer `shopware_ricc` (Filialseite) durfte **von jedem Host** und wurde
am 23.09. auf `10.0.1.%` eingeschränkt; geprüft mit frischer Anmeldung von
beiden Frontends.
