From 985c9ce07e6275153bad23de0047a61bec7d5665 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnrher?=
Date: Sat, 26 Sep 2026 11:51:49 +0200
Subject: [PATCH] Deployment-Rules for second server
---
docker/endeavour/Dockerfile | 104 ++++++
docker/endeavour/Dockerfile.dockerignore | 36 ++
docker/endeavour/compose.yaml | 42 +++
docker/endeavour/deploy.sh | 82 +++++
docker/endeavour/env.production.example | 65 ++++
docker/endeavour/nginx.conf | 54 +++
docker/endeavour/php.ini | 20 ++
docs/implement-endeavour.md | 410 +++++++++++++++++++++++
version | 2 +-
9 files changed, 814 insertions(+), 1 deletion(-)
create mode 100644 docker/endeavour/Dockerfile
create mode 100644 docker/endeavour/Dockerfile.dockerignore
create mode 100644 docker/endeavour/compose.yaml
create mode 100755 docker/endeavour/deploy.sh
create mode 100644 docker/endeavour/env.production.example
create mode 100644 docker/endeavour/nginx.conf
create mode 100644 docker/endeavour/php.ini
create mode 100644 docs/implement-endeavour.md
diff --git a/docker/endeavour/Dockerfile b/docker/endeavour/Dockerfile
new file mode 100644
index 0000000..683459f
--- /dev/null
+++ b/docker/endeavour/Dockerfile
@@ -0,0 +1,104 @@
+# Produktions-Image für endeavour (KeyHelp-Server, siehe docs/implement-endeavour.md).
+# Unabhängig von docker/prod.Dockerfile, das der bestehende Server weiter nutzt.
+#
+# Targets:
+# app – PHP-FPM mit Code, vendor/ und gebauten Assets
+# web – nginx, liefert public/ aus und reicht PHP an "app" weiter
+
+# ---------------------------------------------------------------------------
+# base: PHP 8.5 mit den Extensions, die mareike braucht
+# ---------------------------------------------------------------------------
+FROM php:8.5-fpm-alpine AS base
+
+ARG UID=2000
+ARG GID=2000
+
+RUN apk add --no-cache \
+ imagemagick \
+ libpng \
+ libxml2 \
+ libzip \
+ oniguruma \
+ && apk add --no-cache --virtual .build-deps \
+ $PHPIZE_DEPS \
+ imagemagick-dev \
+ libpng-dev \
+ libxml2-dev \
+ libzip-dev \
+ oniguruma-dev \
+ && pecl install imagick \
+ && docker-php-ext-enable imagick \
+ && docker-php-ext-install mysqli pdo_mysql mbstring zip exif pcntl gd \
+ && apk del .build-deps \
+ && rm -rf /tmp/pear
+
+# Nutzer mit der UID/GID des Host-Nutzers, damit das eingebundene storage/ beschreibbar ist.
+# Existiert die GID im Image schon, wird die vorhandene Gruppe verwendet.
+RUN if getent group "${GID}" > /dev/null; then \
+ GROUP_NAME="$(getent group "${GID}" | cut -d: -f1)"; \
+ else \
+ addgroup -S -g "${GID}" mareike && GROUP_NAME=mareike; \
+ fi \
+ && adduser -S -D -H -u "${UID}" -G "${GROUP_NAME}" mareike
+
+COPY --chmod=0755 docker/php/composer.phar /usr/bin/composer
+COPY docker/endeavour/php.ini /usr/local/etc/php/conf.d/zz-mareike.ini
+
+WORKDIR /var/www/html
+
+# ---------------------------------------------------------------------------
+# vendor: Composer-Abhängigkeiten (ohne Dev-Pakete)
+# ---------------------------------------------------------------------------
+FROM base AS vendor
+
+RUN apk add --no-cache git unzip
+
+COPY composer.json composer.lock ./
+RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction --no-progress
+
+# ---------------------------------------------------------------------------
+# assets: Vite-Build (Vite importiert aus app/, daher der volle Quellcode)
+# ---------------------------------------------------------------------------
+FROM node:24-alpine AS assets
+
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci --no-audit --no-fund
+COPY . .
+RUN npm run build
+
+# ---------------------------------------------------------------------------
+# app: PHP-FPM
+# ---------------------------------------------------------------------------
+FROM base AS app
+
+COPY . .
+COPY --from=vendor /var/www/html/vendor ./vendor
+COPY --from=assets /app/public/build ./public/build
+
+# Der Code gehört root; beschreibbar sind nur storage/ und bootstrap/cache/.
+RUN composer dump-autoload --optimize --no-dev --no-interaction \
+ && mkdir -p \
+ storage/app/private \
+ storage/app/public \
+ storage/framework/cache/data \
+ storage/framework/sessions \
+ storage/framework/views \
+ storage/logs \
+ storage/temp \
+ && chown -R mareike: storage bootstrap/cache
+
+USER mareike
+
+EXPOSE 9000
+CMD ["php-fpm"]
+
+# ---------------------------------------------------------------------------
+# web: nginx vor PHP-FPM
+# ---------------------------------------------------------------------------
+FROM nginx:stable-alpine AS web
+
+COPY docker/endeavour/nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=app /var/www/html/public /var/www/html/public
+
+EXPOSE 80
diff --git a/docker/endeavour/Dockerfile.dockerignore b/docker/endeavour/Dockerfile.dockerignore
new file mode 100644
index 0000000..6dbfb4d
--- /dev/null
+++ b/docker/endeavour/Dockerfile.dockerignore
@@ -0,0 +1,36 @@
+# Build-Kontext für docker/endeavour/Dockerfile.
+# BuildKit nutzt diese Datei anstelle der .dockerignore im Projektroot, die für den bestehenden Server
+# unverändert bleibt. Wichtig: keine .env ins Image – sie wird zur Laufzeit eingebunden.
+
+.git
+.ai
+.junie
+.claude
+.idea
+.fleet
+.vscode
+.zed
+
+.env
+.env.*
+
+node_modules
+vendor
+public/build
+public/hot
+public/storage
+
+storage/*
+bootstrap/cache/*.php
+
+tests
+.phpunit.cache
+.phpunit.result.cache
+
+docker/ssl
+docker/logs
+docker-compose.yaml
+
+_ide_helper.php
+_ide_helper_models.php
+gitlog.log
diff --git a/docker/endeavour/compose.yaml b/docker/endeavour/compose.yaml
new file mode 100644
index 0000000..ef5a473
--- /dev/null
+++ b/docker/endeavour/compose.yaml
@@ -0,0 +1,42 @@
+# Compose-Stack für endeavour (KeyHelp-Server, siehe docs/implement-endeavour.md).
+# Wird über /opt/mareike/bin/deploy.sh gesteuert, das die MAREIKE_*-Variablen setzt.
+
+name: mareike
+
+x-logging: &logging
+ driver: json-file
+ options:
+ max-size: "10m"
+ max-file: "5"
+
+services:
+ app:
+ build:
+ context: ../..
+ dockerfile: docker/endeavour/Dockerfile
+ target: app
+ args:
+ UID: ${MAREIKE_UID:-2000}
+ GID: ${MAREIKE_GID:-2000}
+ image: mareike-endeavour-app:latest
+ restart: unless-stopped
+ volumes:
+ - ${MAREIKE_ENV_FILE:-/opt/mareike/shared/.env}:/var/www/html/.env:ro
+ - ${MAREIKE_STORAGE:-/opt/mareike/storage}:/var/www/html/storage
+ # Socket der KeyHelp-MariaDB auf dem Host (DB_SOCKET=/run/mysqld/mysqld.sock)
+ - ${MAREIKE_DB_SOCKET_DIR:-/run/mysqld}:/run/mysqld
+ logging: *logging
+
+ web:
+ build:
+ context: ../..
+ dockerfile: docker/endeavour/Dockerfile
+ target: web
+ image: mareike-endeavour-web:latest
+ restart: unless-stopped
+ depends_on:
+ - app
+ ports:
+ # Nur lokal erreichbar – nach außen geht es ausschließlich über den KeyHelp-Webserver.
+ - "127.0.0.1:${MAREIKE_HTTP_PORT:-8080}:80"
+ logging: *logging
diff --git a/docker/endeavour/deploy.sh b/docker/endeavour/deploy.sh
new file mode 100755
index 0000000..0d67235
--- /dev/null
+++ b/docker/endeavour/deploy.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+#
+# Deployment von mareike auf endeavour (KeyHelp-Server), siehe docs/implement-endeavour.md.
+#
+# Liegt auf dem Server als KOPIE unter /opt/mareike/bin/deploy.sh – nicht direkt aus src/ ausführen,
+# weil der git-Checkout sonst das laufende Skript austauscht.
+#
+# Aufruf (als root):
+# deploy.sh [REF] Tag, Branch oder Commit ausrollen (Default: main)
+# deploy.sh artisan … php artisan im laufenden app-Container
+# deploy.sh compose … docker compose mit der endeavour-Konfiguration (z. B. "compose logs -f")
+
+set -euo pipefail
+
+MAREIKE_BASE="${MAREIKE_BASE:-/opt/mareike}"
+MAREIKE_USER="${MAREIKE_USER:-mareike-docker}"
+SRC="$MAREIKE_BASE/src"
+
+export MAREIKE_ENV_FILE="$MAREIKE_BASE/shared/.env"
+export MAREIKE_STORAGE="$MAREIKE_BASE/storage"
+export MAREIKE_HTTP_PORT="${MAREIKE_HTTP_PORT:-8080}"
+export MAREIKE_DB_SOCKET_DIR="${MAREIKE_DB_SOCKET_DIR:-/run/mysqld}"
+MAREIKE_UID="$(id -u "$MAREIKE_USER")"
+MAREIKE_GID="$(id -g "$MAREIKE_USER")"
+export MAREIKE_UID MAREIKE_GID
+
+compose() {
+ docker compose -f "$SRC/docker/endeavour/compose.yaml" "$@"
+}
+
+case "${1:-}" in
+ artisan)
+ shift
+ compose exec app php artisan "$@"
+ exit
+ ;;
+ compose)
+ shift
+ compose "$@"
+ exit
+ ;;
+esac
+
+REF="${1:-main}"
+
+if [[ ! -f "$MAREIKE_ENV_FILE" ]]; then
+ echo "Fehlt: $MAREIKE_ENV_FILE (Vorlage: docker/endeavour/env.production.example)" >&2
+ exit 1
+fi
+
+echo "==> Quellcode auf '$REF' bringen"
+git -C "$SRC" fetch --tags --prune --force origin
+if git -C "$SRC" show-ref --verify --quiet "refs/remotes/origin/$REF"; then
+ git -C "$SRC" checkout --force -B "$REF" "origin/$REF"
+else
+ git -C "$SRC" checkout --force --detach "$REF"
+fi
+git -C "$SRC" clean -fd
+
+echo "==> storage/ vorbereiten"
+for dir in \
+ "" \
+ app app/private app/public \
+ framework framework/cache framework/cache/data framework/sessions framework/views \
+ logs temp
+do
+ install -d -m 0775 -o "$MAREIKE_USER" -g "$MAREIKE_GID" "$MAREIKE_STORAGE/$dir"
+done
+
+echo "==> Images bauen"
+compose build --pull
+
+echo "==> Datenbank migrieren"
+compose run --rm --no-deps app php artisan migrate --force
+
+echo "==> Container starten"
+compose up -d --remove-orphans
+compose exec -T app php artisan view:clear
+
+docker image prune -f > /dev/null
+
+echo "==> mareike $(cat "$SRC/version") ($(git -C "$SRC" rev-parse --short HEAD)) läuft."
diff --git a/docker/endeavour/env.production.example b/docker/endeavour/env.production.example
new file mode 100644
index 0000000..83a7c2f
--- /dev/null
+++ b/docker/endeavour/env.production.example
@@ -0,0 +1,65 @@
+# Vorlage für /opt/mareike/shared/.env auf endeavour.
+# Nach dem Kopieren alle <...>-Platzhalter ersetzen. Diese Datei kommt NIE ins Image und NIE ins Repo.
+# Keine ${...}-Verweise auf andere Variablen verwenden, Werte ausschreiben.
+
+APP_NAME=mareike
+APP_ENV=production
+APP_DEBUG=false
+# Erzeugen mit: echo "base64:$(openssl rand -base64 32)" – danach NIE mehr ändern (verschlüsselte Daten!)
+APP_KEY=
+# Domain des Dach-Tenants "lv"
+APP_URL=https://.meine-anmeldung.digital
+# Kein ASSET_URL: Assets kommen von der jeweiligen Tenant-Domain selbst.
+
+APP_LOCALE=de
+APP_FALLBACK_LOCALE=de
+
+LOG_CHANNEL=stack
+LOG_STACK=daily
+LOG_DAILY_DAYS=30
+LOG_LEVEL=warning
+
+# KeyHelp-MariaDB auf dem Host, per Unix-Socket (Verzeichnis /run/mysqld ist in den Container gemountet)
+DB_CONNECTION=mysql
+DB_HOST=localhost
+DB_SOCKET=/run/mysqld/mysqld.sock
+DB_DATABASE=
+DB_USERNAME=
+DB_PASSWORD=
+
+SESSION_DRIVER=database
+SESSION_LIFETIME=120
+SESSION_SECURE_COOKIE=true
+SESSION_DOMAIN=null
+
+CACHE_STORE=database
+QUEUE_CONNECTION=sync
+FILESYSTEM_DISK=local
+
+# Versand über ein KeyHelp-Postfach (STARTTLS auf 587)
+MAIL_MAILER=smtp
+MAIL_SCHEME=smtp
+MAIL_HOST=
+MAIL_PORT=587
+MAIL_USERNAME=
+MAIL_PASSWORD=
+MAIL_FROM_ADDRESS=
+MAIL_FROM_NAME=mareike
+
+# Empfänger der Admin-Benachrichtigungen (z. B. neue Registrierungen)
+APP_ADMIN_MAIL=
+APP_ADMIN_NAME=""
+
+# Maximale Größe hochgeladener Belege in MB (php.ini erlaubt bis 64)
+MAX_INVOICE_FILE_SIZE=16
+
+# WebDAV-Ablage: unbedingt ein EIGENER Ordner, getrennt vom bestehenden Server
+WEBDAV_HOST=//>
+WEBDAV_USER=
+WEBDAV_PASS=
+
+# Prüfdienst für erweiterte Führungszeugnisse
+COC_CHECK_URL=
+
+# MUSS leer bleiben: "bdp-lv-sachsen" würde beim Seeden die Sachsen-Tenants und deren Personen einspielen.
+PROVIDER=
diff --git a/docker/endeavour/nginx.conf b/docker/endeavour/nginx.conf
new file mode 100644
index 0000000..83895aa
--- /dev/null
+++ b/docker/endeavour/nginx.conf
@@ -0,0 +1,54 @@
+# nginx im Container "web" auf endeavour.
+# TLS terminiert der KeyHelp-Webserver auf dem Host und reicht per Proxy an 127.0.0.1:8080 weiter.
+
+# Echte Client-IP aus X-Forwarded-For übernehmen, aber nur von Docker-internen Adressen
+# (der Proxy auf dem Host erreicht den Container über das Bridge-Gateway).
+set_real_ip_from 10.0.0.0/8;
+set_real_ip_from 172.16.0.0/12;
+set_real_ip_from 192.168.0.0/16;
+real_ip_header X-Forwarded-For;
+real_ip_recursive on;
+
+server {
+ listen 80 default_server;
+ server_name _;
+
+ root /var/www/html/public;
+ index index.php;
+
+ server_tokens off;
+ client_max_body_size 70m;
+
+ location / {
+ try_files $uri $uri/ /index.php?$query_string;
+ }
+
+ # Vite-Assets sind versioniert (Hash im Dateinamen) und dürfen lange gecacht werden.
+ location /build/assets/ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ access_log off;
+ try_files $uri =404;
+ }
+
+ location ~ \.php$ {
+ try_files $uri =404;
+ fastcgi_split_path_info ^(.+\.php)(/.+)$;
+ fastcgi_pass app:9000;
+ fastcgi_index index.php;
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+
+ # Nach außen läuft alles über HTTPS (KeyHelp). So erzeugt Laravel https-URLs,
+ # ohne dass im App-Code trustProxies gesetzt werden muss.
+ fastcgi_param HTTPS on;
+ fastcgi_param REQUEST_SCHEME https;
+ fastcgi_param SERVER_PORT 443;
+
+ fastcgi_read_timeout 300;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
diff --git a/docker/endeavour/php.ini b/docker/endeavour/php.ini
new file mode 100644
index 0000000..fb8553f
--- /dev/null
+++ b/docker/endeavour/php.ini
@@ -0,0 +1,20 @@
+; PHP-Einstellungen für endeavour (Container "app").
+
+; Uploads: Obergrenze für MAX_INVOICE_FILE_SIZE (MB, Default 16) in der .env, POST etwas größer für die
+; Formulardaten.
+upload_max_filesize = 64M
+post_max_size = 70M
+
+memory_limit = 512M
+max_execution_time = 120
+
+expose_php = Off
+date.timezone = Europe/Berlin
+
+; OPcache ist ab PHP 8.5 fest eingebaut. Zeitstempel werden weiter geprüft, weil Blade kompilierte
+; Views in storage/ unter gleichem Dateinamen neu schreibt.
+opcache.enable = 1
+opcache.memory_consumption = 128
+opcache.max_accelerated_files = 20000
+opcache.validate_timestamps = 1
+opcache.revalidate_freq = 2
diff --git a/docs/implement-endeavour.md b/docs/implement-endeavour.md
new file mode 100644
index 0000000..9ab0d3a
--- /dev/null
+++ b/docs/implement-endeavour.md
@@ -0,0 +1,410 @@
+# mareike auf endeavour ausrollen (KeyHelp-Server mit Docker)
+
+Diese Anleitung beschreibt, wie mareike **zusätzlich** zum bestehenden Server auf *endeavour* läuft. Endeavour ist ein
+Server, der mit KeyHelp verwaltet wird. Die Parent-Domain ist `meine-anmeldung.digital`.
+
+KeyHelp selbst nutzt kein Docker. mareike läuft deshalb in zwei Containern **neben** KeyHelp. KeyHelp übernimmt
+weiter, was es gut kann: Domains, Let's-Encrypt-Zertifikate, MariaDB, Postfächer und Backups.
+
+> **Der bestehende Server bleibt unberührt.** Für endeavour gibt es eigene Dateien unter `docker/endeavour/`.
+> `docker-compose.prod`, `docker/prod.Dockerfile`, `docker/run-mareike.sh`, `docker/nginx/default.conf` und
+> `.dockerignore` werden nicht verändert und von endeavour nicht benutzt.
+
+---
+
+## 1. Überblick
+
+```
+Browser ──HTTPS──▶ KeyHelp-Webserver (Apache/nginx, Let's Encrypt, eine Domain je Tenant)
+ │ Proxy, Host-Header bleibt erhalten
+ ▼
+ 127.0.0.1:8080 ──▶ Container "web" (nginx, liefert public/ aus)
+ │ FastCGI
+ ▼
+ Container "app" (PHP 8.5-FPM, mareike)
+ │ Unix-Socket /run/mysqld/mysqld.sock
+ ▼
+ KeyHelp-MariaDB (Host)
+```
+
+- **Tenant-Erkennung:** `IdentifyTenant` sucht den Tenant über `tenants.url` = Host der Anfrage. Deshalb muss der
+ Proxy den Host-Header unverändert weitergeben, und jede Tenant-Domain braucht exakt diesen Eintrag in `tenants.url`.
+- **HTTPS:** TLS endet bei KeyHelp. Die nginx im Container meldet PHP trotzdem `HTTPS=on`, damit Laravel `https://`-Links
+ erzeugt. Eine Code-Änderung ist dafür nicht nötig.
+- **Port 8080** ist nur an `127.0.0.1` gebunden und von außen nicht erreichbar.
+
+### Dateien im Repo
+
+| Datei | Zweck |
+|---|---|
+| `docker/endeavour/Dockerfile` | Multi-Stage-Build. Target `app` enthält PHP-FPM, Code, vendor/ und Assets, Target `web` enthält nginx und public/. |
+| `docker/endeavour/Dockerfile.dockerignore` | Eigener Build-Kontext. Unter anderem kommt **keine `.env` ins Image**. |
+| `docker/endeavour/compose.yaml` | Stack mit den Services `app` und `web`. |
+| `docker/endeavour/nginx.conf` | nginx im Container, inkl. `HTTPS on` und echter Client-IP. |
+| `docker/endeavour/php.ini` | Upload-Größen, Speicher, OPcache. |
+| `docker/endeavour/env.production.example` | Vorlage für die `.env` auf dem Server. |
+| `docker/endeavour/deploy.sh` | Deploy-Skript: pullen, bauen, migrieren, starten. |
+
+### Verzeichnisse auf endeavour
+
+```
+/opt/mareike/
+├── bin/deploy.sh Kopie von docker/endeavour/deploy.sh
+├── src/ Git-Checkout (wird bei jedem Deploy aktualisiert)
+├── shared/.env Konfiguration mit Zugangsdaten (nur root lesbar)
+└── storage/ Laravel-storage/ (Logs, Sessions, erzeugte PDFs/ZIPs) – persistent
+```
+
+---
+
+## 2. Docker installieren
+
+Als root auf endeavour. Das Beispiel gilt für Debian, bei Ubuntu steht in den Pfaden `ubuntu` statt `debian`.
+
+```bash
+apt-get update
+apt-get install -y ca-certificates curl
+install -m 0755 -d /etc/apt/keyrings
+curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
+chmod a+r /etc/apt/keyrings/docker.asc
+echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
+ https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
+ > /etc/apt/sources.list.d/docker.list
+apt-get update
+apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
+```
+
+Log-Rotation für alle Container (`/etc/docker/daemon.json`):
+
+```json
+{
+ "log-driver": "json-file",
+ "log-opts": { "max-size": "10m", "max-file": "5" }
+}
+```
+
+Docker soll erst **nach** MariaDB starten, weil der Container das Socket-Verzeichnis `/run/mysqld` einbindet:
+
+```bash
+mkdir -p /etc/systemd/system/docker.service.d
+cat > /etc/systemd/system/docker.service.d/after-mariadb.conf <<'EOF'
+[Unit]
+After=mariadb.service
+Wants=mariadb.service
+EOF
+systemctl daemon-reload
+systemctl restart docker
+docker run --rm hello-world
+```
+
+> **KeyHelp-Firewall:** Docker legt eigene iptables-Regeln an. Lädt KeyHelp seine Firewall neu, können diese Regeln
+> verloren gehen. Die Container sind dann nicht mehr erreichbar (502 im Browser). Abhilfe ist
+> `systemctl restart docker`. Weil mareike nur an `127.0.0.1` lauscht, öffnet Docker nach außen keine Ports.
+
+---
+
+## 3. Systemnutzer, Verzeichnisse, Repository
+
+Die Container laufen mit der UID eines eigenen Host-Nutzers, damit `storage/` sauber beschreibbar ist. Der Name weicht
+bewusst von den KeyHelp-Konten ab, weil KeyHelp für seine Konten selbst Systemnutzer anlegt.
+
+```bash
+getent passwd 2000 || useradd --system --uid 2000 --user-group --no-create-home --shell /usr/sbin/nologin mareike-docker
+# Ist UID 2000 belegt, eine andere freie UID wählen. deploy.sh liest sie automatisch aus.
+
+install -d -m 0755 /opt/mareike /opt/mareike/bin
+install -d -m 0700 /opt/mareike/shared
+install -d -m 0775 -o mareike-docker -g mareike-docker /opt/mareike/storage
+```
+
+Deploy-Key für das Repository anlegen (nur Lesezugriff):
+
+```bash
+ssh-keygen -t ed25519 -f /root/.ssh/mareike_deploy -N "" -C "endeavour-deploy"
+cat /root/.ssh/mareike_deploy.pub # in Gitea beim Repo th.guenther/mareike als Deploy-Key (read-only) eintragen
+
+cat >> /root/.ssh/config <<'EOF'
+Host git.zahlenlabyrinth.de
+ Port 9922
+ User git
+ IdentityFile /root/.ssh/mareike_deploy
+ IdentitiesOnly yes
+EOF
+
+git clone ssh://git@git.zahlenlabyrinth.de:9922/th.guenther/mareike.git /opt/mareike/src
+install -m 0750 /opt/mareike/src/docker/endeavour/deploy.sh /opt/mareike/bin/deploy.sh
+```
+
+> Ändert sich `docker/endeavour/deploy.sh` im Repo, danach den `install`-Befehl erneut ausführen.
+
+---
+
+## 4. Datenbank in KeyHelp anlegen
+
+1. Im KeyHelp-Panel ein Konto für mareike anlegen oder ein vorhandenes wählen, z. B. `anmeldung`. Unter diesem Konto
+ liegen später auch die Domains.
+2. Unter **Datenbanken** eine neue MariaDB-Datenbank mit Benutzer anlegen. Der Benutzer ist `@localhost`. Den
+ Remote-Zugriff **nicht** aktivieren, denn der Container verbindet sich über den Unix-Socket und gilt damit als
+ `localhost`.
+3. Den Socket-Pfad prüfen:
+
+ ```bash
+ mysql -e "SELECT @@socket;" # erwartet: /run/mysqld/mysqld.sock
+ ```
+
+ Liegt der Socket woanders, in `/opt/mareike/bin/deploy.sh` die Variable `MAREIKE_DB_SOCKET_DIR` auf das Verzeichnis
+ setzen und `DB_SOCKET` in der `.env` anpassen.
+
+Weil die Datenbank von KeyHelp verwaltet wird, ist sie automatisch im **KeyHelp-Backup** enthalten.
+
+---
+
+## 5. `.env` anlegen
+
+```bash
+install -m 0600 /opt/mareike/src/docker/endeavour/env.production.example /opt/mareike/shared/.env
+echo "base64:$(openssl rand -base64 32)" # Ergebnis als APP_KEY eintragen
+nano /opt/mareike/shared/.env
+```
+
+Die wichtigsten Punkte:
+
+- `APP_KEY` wird einmal erzeugt und dann **nie wieder geändert**, weil Laravel damit verschlüsselte Daten sonst nicht
+ mehr lesen kann.
+- `APP_URL` ist die Domain des Dach-Tenants `lv` (siehe Abschnitt 6).
+- `DB_*` sind die Daten aus Schritt 4. `DB_HOST=localhost` und `DB_SOCKET` bleiben wie in der Vorlage.
+- `MAIL_*`: Am einfachsten ist ein Postfach in KeyHelp, etwa `noreply@meine-anmeldung.digital`. Als Host den
+ Mail-Hostnamen des Servers eintragen, mit Port 587.
+- `WEBDAV_*`: **eigener Zielordner**, damit sich endeavour und der bestehende Server nicht gegenseitig Dateien
+ überschreiben.
+- `PROVIDER` **muss leer bleiben.** Mit `bdp-lv-sachsen` würde `db:seed` die Sachsen-Tenants samt Personendaten
+ einspielen.
+- `ASSET_URL` nicht setzen, dann kommen die Assets von der jeweiligen Tenant-Domain.
+- In der Datei keine `${VAR}`-Verweise verwenden, sondern alle Werte ausschreiben.
+
+---
+
+## 6. Erstinstallation
+
+### 6.1 Erstes Deployment
+
+```bash
+/opt/mareike/bin/deploy.sh main # oder einen Release-Tag, z. B. 4.9.1
+```
+
+Das Skript holt den Code, baut beide Images, legt die Tabellen per `migrate` an und startet die Container. Der erste
+Build dauert ein paar Minuten.
+
+Kurztest:
+
+```bash
+/opt/mareike/bin/deploy.sh compose ps
+curl -sI http://127.0.0.1:8080/ | head -1 # 404 ist hier richtig: es gibt noch keinen Tenant
+```
+
+### 6.2 Stammdaten einspielen
+
+```bash
+/opt/mareike/bin/deploy.sh artisan db:seed --force
+```
+
+Mit leerem `PROVIDER` läuft nur der `ProductionDataSeeder`. Er legt Rollen, Status, Zahlungsarten, Kostenstellentypen
+und die Befreiungsgründe an. Den Befehl **nur einmal** ausführen, ein zweiter Lauf würde Duplikate anlegen.
+
+### 6.3 Dach-Tenant `lv` und Cron-Aufgaben anlegen
+
+Der Slug `lv` ist im Code fest als Dach-Ebene verdrahtet (`LvOnlyMiddleware`, Registrierung, Tenant-Verwaltung).
+endeavour braucht deshalb genau einen Tenant mit diesem Slug. Seine `url` ist die Domain, die in Abschnitt 7 in KeyHelp
+angelegt wird.
+
+```bash
+/opt/mareike/bin/deploy.sh artisan tinker
+```
+
+```php
+App\Models\Tenant::create([
+ 'slug' => 'lv',
+ 'name' => '',
+ 'url' => '.meine-anmeldung.digital',
+ 'email' => '',
+ 'email_finance' => '',
+ 'account_name' => '',
+ 'account_iban' => '',
+ 'account_bic' => '',
+ 'city' => '',
+ 'postcode' => '',
+ 'is_active_local_group' => true,
+ 'has_active_instance' => true,
+]);
+
+use App\Enumerations\CronTaskType;
+App\Models\CronTask::create(['name' => 'UploadInvoices', 'execution_type' => CronTaskType::CRON_TASK_TYPE_REALTIME]);
+App\Models\CronTask::create(['name' => 'CloseCostUnit', 'execution_type' => CronTaskType::CRON_TASK_TYPE_DAILY, 'schedule_time' => '00:05']);
+App\Models\CronTask::create(['name' => 'CloseEvent', 'execution_type' => CronTaskType::CRON_TASK_TYPE_DAILY, 'schedule_time' => '00:10']);
+App\Models\CronTask::create(['name' => 'NotifyTeam', 'execution_type' => CronTaskType::CRON_TASK_TYPE_DAILY, 'schedule_time' => '20:00']);
+```
+
+Die Cron-Aufgaben entsprechen denen des bestehenden Servers (`BdpLvSachsenDataSeeder::installCronTasks()`). Sie gelten
+global und werden für jeden aktiven Tenant ausgeführt.
+
+### 6.4 Erste Administratorin / ersten Administrator
+
+1. Auf `https://.meine-anmeldung.digital` ganz normal registrieren. Voraussetzung: Abschnitt 7 ist
+ erledigt und der Mailversand funktioniert.
+2. Das Konto zur Administration hochstufen:
+
+ ```bash
+ mysql -e "UPDATE users SET user_role_main='ROLE_ADMINISTRATOR', user_role_local_group='ROLE_GROUP_LEADER', active=1 WHERE email='';"
+ ```
+
+---
+
+## 7. Tenant-Domain in KeyHelp einrichten (je Tenant)
+
+Diesen Ablauf für den Dach-Tenant `lv` und später für jede weitere Gruppe wiederholen.
+
+### 7.1 Einmalig: Proxy-Module aktivieren (Apache)
+
+```bash
+a2enmod proxy proxy_http headers
+systemctl reload apache2
+```
+
+### 7.2 Domain anlegen
+
+1. KeyHelp → **Domains** → neue Domain für das mareike-Konto, z. B. `wilde-moehre.meine-anmeldung.digital`.
+ Den DNS-Eintrag (A/AAAA) auf endeavour setzen, falls KeyHelp den DNS nicht selbst verwaltet.
+2. **SSL/TLS:** Let's Encrypt aktivieren und **HTTPS erzwingen** einschalten.
+3. PHP wird für diese Domain nicht gebraucht.
+4. In den Domain-Einstellungen im Feld für **individuelle Webserver-Direktiven** (nur als Admin sichtbar, die
+ Bezeichnung variiert je nach KeyHelp-Version) Folgendes eintragen:
+
+ **Apache** (KeyHelp-Standard):
+
+ ```apache
+ ProxyPreserveHost On
+ ProxyRequests Off
+ RequestHeader set X-Forwarded-Proto "https"
+ ProxyPass /.well-known/acme-challenge/ !
+ ProxyPass / http://127.0.0.1:8080/
+ ProxyPassReverse / http://127.0.0.1:8080/
+ ```
+
+ **nginx** (falls KeyHelp auf nginx läuft):
+
+ ```nginx
+ location / {
+ proxy_pass http://127.0.0.1:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto https;
+ client_max_body_size 70m;
+ }
+ ```
+
+ Die Ausnahme für `/.well-known/acme-challenge/` sorgt dafür, dass KeyHelp die Zertifikate weiter selbst verlängern
+ kann.
+5. Speichern und die Domain aufrufen. Solange der Tenant noch nicht existiert, zeigt mareike **404 „Tenant not
+ found“**. Das ist ein gutes Zeichen, denn der Proxy funktioniert.
+
+### 7.3 Tenant in mareike anlegen
+
+Im Dach-Tenant `lv` unter der Tenant-Verwaltung den neuen Tenant anlegen. Als URL **exakt** die Domain ohne `https://`
+und ohne Schrägstrich eintragen, z. B. `wilde-moehre.meine-anmeldung.digital`.
+
+Für den Tenant `lv` selbst ist das bereits in Schritt 6.3 passiert.
+
+---
+
+## 8. Cron einrichten
+
+mareike hat keinen Laravel-Scheduler. Die Aufgaben laufen über die URL `/execute-crons`, die für **alle** aktiven
+Tenants arbeitet. Sie muss einmal pro Minute aufgerufen werden, dabei genügt der Host-Header eines beliebigen aktiven
+Tenants:
+
+```bash
+cat > /etc/cron.d/mareike <<'EOF'
+* * * * * root curl -fsS -o /dev/null -H "Host: .meine-anmeldung.digital" http://127.0.0.1:8080/execute-crons
+EOF
+```
+
+Kontrolle: `/opt/mareike/storage/logs/` enthält die Task-Logs.
+
+---
+
+## 9. Updates ausrollen
+
+```bash
+/opt/mareike/bin/deploy.sh 4.9.2 # Release-Tag
+/opt/mareike/bin/deploy.sh main # oder Branch
+```
+
+Das Skript läuft in dieser Reihenfolge:
+
+1. `git fetch` und Checkout des Standes.
+2. `storage/`-Ordner prüfen.
+3. Images neu bauen, dabei werden Basis-Images mit Sicherheitsupdates frisch gezogen.
+4. `migrate --force` ausführen.
+5. Container neu starten.
+6. Kompilierte Views leeren.
+7. Alte Images aufräumen.
+
+Nützliche Kurzbefehle:
+
+```bash
+/opt/mareike/bin/deploy.sh compose ps
+/opt/mareike/bin/deploy.sh compose logs -f web
+/opt/mareike/bin/deploy.sh artisan migrate:status
+/opt/mareike/bin/deploy.sh artisan tinker
+```
+
+**Rollback:** Den vorherigen Tag ausrollen (`deploy.sh 4.9.1`). Migrationen werden dabei **nicht** zurückgedreht.
+Enthält ein Release Datenbankänderungen, deshalb vorher ein DB-Backup ziehen.
+
+---
+
+## 10. Backup
+
+| Was | Wie |
+|---|---|
+| Datenbank | Über das KeyHelp-Backup, weil die DB von KeyHelp verwaltet wird. |
+| `/opt/mareike/storage` | **Nicht** im KeyHelp-Backup. Zusätzlich sichern, z. B. per borg/restic oder rsync auf das Backup-Ziel. |
+| `/opt/mareike/shared/.env` | Ebenfalls zusätzlich sichern, besonders wegen des `APP_KEY`. |
+| Code und Images | Nicht nötig, lassen sich jederzeit aus dem Repo neu bauen. |
+
+---
+
+## 11. Fehlersuche
+
+| Symptom | Ursache / Lösung |
+|---|---|
+| 404 „Tenant not found“ | Der Host passt nicht zu `tenants.url` (Tippfehler, `https://` oder `/` im Eintrag), der Tenant ist nicht `has_active_instance`, oder der Proxy reicht den Host nicht durch (`ProxyPreserveHost On` fehlt). |
+| 502/503 vom KeyHelp-Webserver | Container laufen nicht (`deploy.sh compose ps`) oder die Firewall wurde neu geladen, dann hilft `systemctl restart docker`. |
+| Seite lädt ohne CSS, Mixed Content | `ASSET_URL` ist gesetzt oder die nginx-Config im Container wurde nicht übernommen (`fastcgi_param HTTPS on`). |
+| `SQLSTATE[HY000] [2002]` | Socket nicht erreichbar. `SELECT @@socket` prüfen, `MAREIKE_DB_SOCKET_DIR` bzw. `DB_SOCKET` anpassen, nach einem MariaDB-Neustart den Stack mit `deploy.sh compose restart app` neu starten. |
+| `Permission denied` in storage/ | Besitzer von `/opt/mareike/storage` muss `mareike-docker` sein, der nächste `deploy.sh`-Lauf korrigiert das. |
+| Upload bricht ab | Grenzen prüfen: `MAX_INVOICE_FILE_SIZE` in der `.env`, 64 MB in `php.ini`, 70 MB in `nginx.conf`. |
+| Mails kommen nicht an | `MAIL_*` prüfen, Log in `storage/logs/`. Absenderadresse muss zum Postfach passen (SPF/DKIM der Domain in KeyHelp). |
+
+Logs:
+
+```bash
+/opt/mareike/bin/deploy.sh compose logs --tail=200 app web
+ls -lt /opt/mareike/storage/logs/
+```
+
+---
+
+## 12. Abgrenzung zum bestehenden Server
+
+| | bestehender Server | endeavour |
+|---|---|---|
+| Dockerfile | `docker/prod.Dockerfile` | `docker/endeavour/Dockerfile` |
+| Compose | `docker-compose.prod` | `docker/endeavour/compose.yaml` |
+| nginx | `docker/nginx/default.conf` | `docker/endeavour/nginx.conf` |
+| Build-Kontext | `.dockerignore` | `docker/endeavour/Dockerfile.dockerignore` |
+| Datenbank | eigene | KeyHelp-MariaDB auf endeavour |
+| Tenants | `*.mareike.sachsen.pfadfinden.de` | `*.meine-anmeldung.digital` |
+
+Beide Server nutzen denselben Code-Stand aus demselben Repository, haben aber **getrennte** Datenbanken, `.env`,
+WebDAV-Ordner und Tenants. Änderungen an `docker/endeavour/` wirken nur auf endeavour.
diff --git a/version b/version
index 5b341fd..dad10c7 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-4.9.1
+4.9.2