diff --git a/deploy/README.md b/deploy/README.md
new file mode 100644
index 0000000..3eb75d7
--- /dev/null
+++ b/deploy/README.md
@@ -0,0 +1,98 @@
+# Deployment aircard-ios.org
+
+Die Seite wird auf dem Webklar-Server aus dem Gitea-Repo `knso/AirCardIOS` gebaut und von nginx ausgeliefert. Traefik stellt TLS und Weiterleitungen. Dieser Ordner enthält alles, was dafür im Repo liegt; die Dateien auf dem Server sind Kopien davon.
+
+## Bestandteile
+
+| Datei im Repo | Ziel auf dem Server (`/home/webklar/apps/`) | Aufgabe |
+|---|---|---|
+| `deploy/deploy.sh` | `aircard-ios.org/deploy.sh` (ausführbar) | klont/aktualisiert das Repo, baut `web/` und kopiert `dist/` nach `out/` |
+| `deploy/webhook-server.js` | `aircard-ios.org/webhook-server.js` | nimmt den Gitea-Webhook an, startet `deploy.sh` nacheinander, pollt alle 15 Minuten |
+| `deploy/nginx/aircard-ios.org.conf` | `aircard-ios.org/nginx/aircard-ios.org.conf` | nginx-Konfiguration der Seite |
+| `deploy/traefik/aircard.yml` | `traefik/dynamic/aircard.yml` | Route `/v1` mit Header `X-Appwrite-Project: aircard` an Appwrite |
+| `deploy/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build` und `aircard` |
+
+Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repos, Arbeitsordner des Build-Containers), `out/` (gebaute Seite, von nginx ausgeliefert), `dev/` (Arbeitskopie für die Entwicklung, nicht gemountet).
+
+## Einrichten (einmalig)
+
+```sh
+APP=/home/webklar/apps
+SRC=$APP/aircard-ios.org/dev/deploy
+
+mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.org/nginx
+cp $SRC/deploy.sh $APP/aircard-ios.org/deploy.sh && chmod +x $APP/aircard-ios.org/deploy.sh
+cp $SRC/webhook-server.js $APP/aircard-ios.org/webhook-server.js
+cp $SRC/nginx/aircard-ios.org.conf $APP/aircard-ios.org/nginx/aircard-ios.org.conf
+
+# Compose: Backup, dann den Inhalt von compose-snippet.yml unter `services:` direkt nach dem eship-Block einfügen
+cp $APP/docker-compose.yml $APP/docker-compose.yml.bak-2026-10-04-aircard
+cd $APP && docker compose config -q; echo $? # 0; einzige erlaubte Ausgabe: Warnung "attribute `version` is obsolete"
+
+# Traefik-Route atomar ablegen (Datei-Provider lädt sie ohne Neustart)
+cp $SRC/traefik/aircard.yml $APP/traefik/dynamic/aircard.yml.tmp && mv $APP/traefik/dynamic/aircard.yml.tmp $APP/traefik/dynamic/aircard.yml
+```
+
+Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff auf `knso/AirCardIOS`) und `GITEA_DEPLOY_WEBHOOK_SECRET` enthalten. Beide Werte gehören nie in Logs, Chats oder Commits.
+
+## Starten
+
+```sh
+cd /home/webklar/apps && docker compose up -d aircard-build aircard
+```
+
+Nur diese beiden Dienste starten, nie ein nacktes `docker compose up -d` (das würde nicht laufende Dienste wie eship-build wieder anlegen). Der Build-Container installiert beim Start `git`, startet den Webhook-Server und baut sofort einmal (`initial`). Den Fortschritt zeigt `docker logs -f webklar-aircard-build`; am Ende steht `Deployment abgeschlossen` (oder eine Zeile mit `ERROR:`).
+
+Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.env`) beendet sich der Webhook-Server mit Exit-Code 1, statt Anfragen ungeprüft durchzulassen.
+
+## Manuell deployen
+
+```sh
+docker exec webklar-aircard-build sh /deploy.sh
+```
+
+Ohne `DEPLOY_TRIGGER` baut das Skript immer neu, auch wenn sich der Commit nicht geändert hat.
+
+## Gitea-Webhook
+
+Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea:
+
+| Feld | Wert |
+|---|---|
+| Ziel-URL | `https://aircard-ios.org/webhook/deploy` |
+| HTTP-Methode | POST |
+| Content-Type | `application/json` |
+| Secret | Wert von `GITEA_DEPLOY_WEBHOOK_SECRET` aus `/home/webklar/apps/.env` (volle Länge, nur dort nachsehen) |
+| Auslöser | nur Push-Ereignisse |
+| Branch-Filter | `main` |
+| Aktiv | ja |
+
+„Test delivery“ muss `202` ergeben. `401` heißt: Secret stimmt nicht. `200` mit `{"ignored":true}` heißt: Push auf einen anderen Branch als `main`.
+
+## Verhalten
+
+- **Webhook** (`POST /webhook/deploy`): prüft die Signatur im Header `x-gitea-signature` (HMAC-SHA256 über den Rohbody). `401` bei falscher oder fehlender Signatur, `413` bei Body über 1 MB, `200` bei Push auf einen anderen Branch, `202` bei Push auf `main`; der Build läuft danach im Hintergrund. Alles andere antwortet `404`.
+- **Warteschlange**: Builds laufen nacheinander, höchstens einer läuft und einer wartet. Weitere Pushes während ein Lauf wartet, werden mit `202` beantwortet und fügen keinen Lauf hinzu (der wartende Lauf holt ohnehin den neuesten Stand).
+- **Trigger**: `webhook` und manuelle Aufrufe bauen immer. `initial` (Container-Start) und `poll` (alle 15 Minuten) bauen nur, wenn es einen neuen Commit gibt oder `out/index.html` fehlt.
+- **Gemerkter Commit**: `build/.git/aircard-last-deployed-commit` enthält den zuletzt **versuchten** Commit, geschrieben vor dem Build. Ein kaputter Build wird deshalb nicht alle 15 Minuten wiederholt; die alte Seite bleibt online, bis ein neuer Commit oder ein Webhook-/manueller Lauf kommt.
+- **Fehler**: Ein fehlgeschlagener Lauf wird mit `[webhook] … deployment failed` protokolliert und beendet den Server nicht. Fehlerzeilen des Skripts beginnen mit `ERROR:`.
+- **Build-Variablen**: `VITE_APPWRITE_ENDPOINT` und `VITE_APPWRITE_PROJECT_ID` kommen aus der Container-Umgebung (compose); Vite liest sie direkt.
+
+## Änderungen später
+
+- `deploy.sh` ändern: Datei auf dem Server mit `cp` überschreiben (nicht `mv`, sonst zeigt der Datei-Mount auf die alte Datei). Gilt beim nächsten Lauf.
+- `webhook-server.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-build`.
+- nginx-Konfiguration ändern: mit `cp` ersetzen, dann `docker exec webklar-aircard nginx -t && docker exec webklar-aircard nginx -s reload`.
+- Traefik-Route ändern: Datei atomar ersetzen wie oben; Löschen der Datei entfernt die Route wieder.
+
+## www.aircard-ios.org
+
+Solange `dig +short www.aircard-ios.org @8.8.8.8` nichts liefert, gibt es nur die Hauptdomain. Zeigt der A-Record auf `91.99.156.85`, werden die auskommentierten www-Labels am Dienst `aircard` in `docker-compose.yml` aktiviert (Backup vorher, dann `docker compose config -q` und `docker compose up -d aircard`). Sie leiten per 301 auf `https://aircard-ios.org` weiter und haben eigene Router, damit ein www-Fehler das Zertifikat der Hauptdomain nicht blockiert.
+
+## Tests
+
+```sh
+node --test deploy/webhook-server.test.js
+```
+
+Die Tests brauchen nur Node und starten die Server auf Port 0.
diff --git a/deploy/compose-snippet.yml b/deploy/compose-snippet.yml
new file mode 100644
index 0000000..635efb9
--- /dev/null
+++ b/deploy/compose-snippet.yml
@@ -0,0 +1,81 @@
+ # ==============================================
+ # AIRCARD-IOS.ORG - Automatisches Deployment aus Gitea
+ # URL: aircard-ios.org
+ # Repository: https://git.webklar.com/knso/AirCardIOS
+ # Einfügen unter `services:` direkt nach dem eship-Block (siehe deploy/README.md).
+ # Starten NUR mit: docker compose up -d aircard-build aircard
+ # (ein nacktes `up -d` würde andere Dienste wieder anlegen)
+ # Die Appwrite-Route liegt als Datei in traefik/dynamic/aircard.yml.
+ # ==============================================
+ aircard-build:
+ image: node:22-alpine
+ container_name: webklar-aircard-build
+ restart: unless-stopped
+ init: true
+ mem_limit: 768m
+ working_dir: /app
+ environment:
+ - GITEA_URL=https://git.webklar.com
+ - GITEA_USER=knso
+ - GITEA_REPO=AirCardIOS
+ - GITEA_TOKEN=${GITEA_DEPLOY_TOKEN}
+ - APP_SUBDIR=web
+ - DEPLOY_BRANCH=main
+ - AIRCARD_DEPLOY_BRANCH=main
+ - AIRCARD_WEBHOOK_TOKEN=${GITEA_DEPLOY_WEBHOOK_SECRET}
+ - AIRCARD_WEBHOOK_PORT=8087
+ - AIRCARD_DEPLOY_SCRIPT=/deploy.sh
+ - POLL_INTERVAL_MINUTES=15
+ - NODE_OPTIONS=--max-old-space-size=512
+ - VITE_APPWRITE_ENDPOINT=https://aircard-ios.org/v1
+ - VITE_APPWRITE_PROJECT_ID=aircard
+ volumes:
+ - ./aircard-ios.org/build:/app
+ - ./aircard-ios.org/out:/out
+ - ./aircard-ios.org/deploy.sh:/deploy.sh:ro
+ - ./aircard-ios.org/webhook-server.js:/webhook/server.js:ro
+ networks:
+ - webklar-network
+ command: /bin/sh -c "apk add --no-cache git && node /webhook/server.js"
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.aircard-webhook.rule=Host(`aircard-ios.org`) && Path(`/webhook/deploy`)"
+ - "traefik.http.routers.aircard-webhook.entrypoints=websecure"
+ - "traefik.http.routers.aircard-webhook.tls.certresolver=letsencrypt"
+ - "traefik.http.routers.aircard-webhook.priority=200"
+ - "traefik.http.services.aircard-webhook.loadbalancer.server.port=8087"
+
+ aircard:
+ image: nginx:alpine
+ container_name: webklar-aircard
+ restart: unless-stopped
+ mem_limit: 64m
+ volumes:
+ - ./aircard-ios.org/out:/usr/share/nginx/html:ro
+ - ./aircard-ios.org/nginx/aircard-ios.org.conf:/etc/nginx/conf.d/default.conf:ro
+ networks:
+ - webklar-network
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.aircard.rule=Host(`aircard-ios.org`)"
+ - "traefik.http.routers.aircard.entrypoints=websecure"
+ - "traefik.http.routers.aircard.tls.certresolver=letsencrypt"
+ - "traefik.http.services.aircard.loadbalancer.server.port=80"
+ - "traefik.http.middlewares.aircard-https.redirectscheme.scheme=https"
+ - "traefik.http.middlewares.aircard-https.redirectscheme.permanent=true"
+ - "traefik.http.routers.aircard-http.rule=Host(`aircard-ios.org`)"
+ - "traefik.http.routers.aircard-http.entrypoints=web"
+ - "traefik.http.routers.aircard-http.middlewares=aircard-https"
+ # --- www (Step 10): erst einkommentieren, wenn www.aircard-ios.org per DNS auf 91.99.156.85 zeigt.
+ # Eigene Router, damit ein www-Fehler (Zertifikat) die Hauptdomain nicht blockiert.
+ # `$$` ist das Escape für ein einzelnes `$` in docker compose; `\\.` ergibt im YAML ein `\.`.
+ # - "traefik.http.routers.aircard-www.rule=Host(`www.aircard-ios.org`)"
+ # - "traefik.http.routers.aircard-www.entrypoints=websecure"
+ # - "traefik.http.routers.aircard-www.tls.certresolver=letsencrypt"
+ # - "traefik.http.routers.aircard-www.middlewares=aircard-www"
+ # - "traefik.http.routers.aircard-www-http.rule=Host(`www.aircard-ios.org`)"
+ # - "traefik.http.routers.aircard-www-http.entrypoints=web"
+ # - "traefik.http.routers.aircard-www-http.middlewares=aircard-www"
+ # - "traefik.http.middlewares.aircard-www.redirectregex.regex=^https?://www\\.aircard-ios\\.org(.*)"
+ # - "traefik.http.middlewares.aircard-www.redirectregex.replacement=https://aircard-ios.org$${1}"
+ # - "traefik.http.middlewares.aircard-www.redirectregex.permanent=true"
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
new file mode 100755
index 0000000..1bae102
--- /dev/null
+++ b/deploy/deploy.sh
@@ -0,0 +1,133 @@
+#!/bin/sh
+# AirCard-Deployment (läuft im Container webklar-aircard-build, node:22-alpine, POSIX sh).
+#
+# Ablauf: Repo klonen bzw. aktualisieren -> (bei poll/initial) überspringen, wenn dieser Commit
+# schon versucht wurde -> npm ci -> Build -> web/dist nach $OUT_DIR kopieren.
+#
+# Aufruf: durch webhook-server.js (DEPLOY_TRIGGER=initial|poll|webhook) oder von Hand:
+# docker exec webklar-aircard-build sh /deploy.sh
+#
+# Variablen (Standardwerte in Klammern):
+# REPO_DIR (/app) OUT_DIR (/out) GITEA_URL (https://git.webklar.com) GITEA_USER (knso)
+# GITEA_REPO (AirCardIOS) GITEA_TOKEN (leer = anonym) APP_SUBDIR (web) DEPLOY_BRANCH (main)
+# DEPLOY_TRIGGER (leer = manual)
+#
+# Fehlerzeilen beginnen mit "ERROR:"; der letzte Satz eines erfolgreichen Builds ist
+# "Deployment abgeschlossen".
+set -e
+
+# Jeder Abbruch soll eine ERROR-Zeile hinterlassen, auch wenn set -e ihn auslöst.
+ERROR_PRINTED=""
+fail() {
+ echo "ERROR: $*"
+ ERROR_PRINTED=1
+ exit 1
+}
+trap 'status=$?; if [ "${status}" -ne 0 ] && [ -z "${ERROR_PRINTED}" ]; then echo "ERROR: Deployment unerwartet abgebrochen (Exit-Code ${status})"; fi' EXIT
+
+REPO_DIR="${REPO_DIR:-/app}"
+OUT_DIR="${OUT_DIR:-/out}"
+GITEA_URL="${GITEA_URL:-https://git.webklar.com}"
+GITEA_URL="${GITEA_URL%/}"
+GITEA_USER="${GITEA_USER:-knso}"
+GITEA_REPO="${GITEA_REPO:-AirCardIOS}"
+GITEA_TOKEN="${GITEA_TOKEN:-}"
+APP_SUBDIR="${APP_SUBDIR:-web}"
+DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
+
+# Clone-URL: mit Token nur, wenn GITEA_TOKEN gesetzt ist. Die URL mit Token wird nie ausgegeben.
+CLEAN_URL="${GITEA_URL}/${GITEA_USER}/${GITEA_REPO}.git"
+if [ -n "${GITEA_TOKEN}" ]; then
+ case "${GITEA_URL}" in
+ https://*) REPO_URL="https://${GITEA_TOKEN}@${GITEA_URL#https://}/${GITEA_USER}/${GITEA_REPO}.git" ;;
+ http://*) REPO_URL="http://${GITEA_TOKEN}@${GITEA_URL#http://}/${GITEA_USER}/${GITEA_REPO}.git" ;;
+ *) fail "GITEA_URL muss mit https:// oder http:// beginnen" ;;
+ esac
+else
+ REPO_URL="${CLEAN_URL}"
+fi
+
+# git-Aufruf, dessen Ausgabe nie eine URL mit Zugangsdaten verrät (git zeigt sie in manchen Fehlermeldungen).
+run_git() {
+ _git_log="$(mktemp)"
+ if "$@" >"${_git_log}" 2>&1; then
+ _git_rc=0
+ else
+ _git_rc=$?
+ fi
+ sed 's#//[^/@ ]*@#//***@#g' "${_git_log}"
+ rm -f "${_git_log}"
+ return "${_git_rc}"
+}
+
+echo "=========================================="
+echo "AirCard Deployment (Trigger: ${DEPLOY_TRIGGER:-manual})"
+echo "=========================================="
+echo "Repository: ${GITEA_URL}/${GITEA_USER}/${GITEA_REPO} (Branch ${DEPLOY_BRANCH})"
+echo ""
+
+mkdir -p "${REPO_DIR}" "${OUT_DIR}" || fail "Verzeichnisse ${REPO_DIR} und ${OUT_DIR} nicht anlegbar"
+
+if [ ! -d "${REPO_DIR}/.git" ]; then
+ echo "Klone Repository..."
+ # Reste eines abgebrochenen Klons entfernen (inkl. versteckter Dateien); das Verzeichnis selbst ist ein Mount.
+ rm -rf "${REPO_DIR}"/* "${REPO_DIR}"/.[!.]* "${REPO_DIR}"/..?*
+ run_git git clone --quiet --branch "${DEPLOY_BRANCH}" "${REPO_URL}" "${REPO_DIR}" ||
+ fail "Repository konnte nicht geklont werden"
+fi
+
+cd "${REPO_DIR}" || fail "Verzeichnis ${REPO_DIR} nicht erreichbar"
+
+echo "Aktualisiere Repository..."
+# Remote-URL bei jedem Lauf neu setzen, damit ein getauschtes Token greift.
+git remote set-url origin "${REPO_URL}" || fail "git remote set-url fehlgeschlagen"
+run_git git fetch --quiet origin "+refs/heads/${DEPLOY_BRANCH}:refs/remotes/origin/${DEPLOY_BRANCH}" ||
+ fail "git fetch für Branch ${DEPLOY_BRANCH} fehlgeschlagen"
+# Token nur für clone/fetch: danach aus der Remote-URL entfernen, damit er nicht in .git/config liegen
+# bleibt (dort könnten ihn die Build-Skripte lesen).
+git remote set-url origin "${CLEAN_URL}" || fail "git remote set-url fehlgeschlagen"
+git reset --hard "origin/${DEPLOY_BRANCH}" >/dev/null || fail "git reset auf origin/${DEPLOY_BRANCH} fehlgeschlagen"
+git clean -fd >/dev/null || fail "git clean fehlgeschlagen"
+
+CURRENT_COMMIT="$(git rev-parse HEAD)" || fail "git rev-parse HEAD fehlgeschlagen"
+echo "Commit: ${CURRENT_COMMIT}"
+
+# Beim Neustart (initial) und beim Polling nur bauen, wenn es einen neuen Commit gibt.
+# Webhook-, manuelle und Aufrufe ohne Trigger bauen immer.
+MARKER="${REPO_DIR}/.git/aircard-last-deployed-commit"
+case "${DEPLOY_TRIGGER:-manual}" in
+ poll | initial)
+ if [ -f "${MARKER}" ] && [ "$(cat "${MARKER}")" = "${CURRENT_COMMIT}" ] && [ -f "${OUT_DIR}/index.html" ]; then
+ echo "Commit ${CURRENT_COMMIT} unverändert, Build übersprungen."
+ exit 0
+ fi
+ ;;
+esac
+
+# Gemerkt wird der zuletzt VERSUCHTE Commit (vor dem Build): ein kaputter Build wird nicht alle
+# 15 Minuten wiederholt, und die alte Seite bleibt online.
+echo "${CURRENT_COMMIT}" >"${MARKER}" || fail "Marker ${MARKER} nicht schreibbar"
+
+[ -d "${REPO_DIR}/${APP_SUBDIR}" ] || fail "Ordner ${APP_SUBDIR} fehlt im Repository"
+cd "${REPO_DIR}/${APP_SUBDIR}"
+
+# Zugangsdaten nicht an npm und die Build-Skripte weiterreichen.
+unset GITEA_TOKEN REPO_URL
+
+echo ""
+echo "Installiere Abhängigkeiten..."
+npm ci --no-audit --no-fund || fail "npm ci fehlgeschlagen"
+
+echo ""
+echo "Baue die Seite..."
+# VITE_*-Variablen kommen direkt aus der Container-Umgebung (compose).
+rm -rf dist
+npm run build || fail "Build fehlgeschlagen"
+[ -f dist/index.html ] || fail "dist/index.html fehlt nach dem Build"
+
+echo ""
+echo "Kopiere nach ${OUT_DIR}..."
+rm -rf "${OUT_DIR}"/* "${OUT_DIR}"/.[!.]* "${OUT_DIR}"/..?*
+cp -r dist/. "${OUT_DIR}/" || fail "Kopieren nach ${OUT_DIR} fehlgeschlagen"
+
+echo "Deployment abgeschlossen"
diff --git a/deploy/nginx/aircard-ios.org.conf b/deploy/nginx/aircard-ios.org.conf
new file mode 100644
index 0000000..f47a473
--- /dev/null
+++ b/deploy/nginx/aircard-ios.org.conf
@@ -0,0 +1,66 @@
+# nginx für aircard-ios.org (Container webklar-aircard, nginx:alpine; wird nach
+# /etc/nginx/conf.d/default.conf gemountet). Ausgeliefert wird die statische Seite aus
+# /usr/share/nginx/html (= out/ des Build-Containers). TLS macht Traefik davor.
+#
+# Wichtig: In den location-Blöcken kein add_header verwenden - sonst entfallen die
+# Sicherheits-Header vom server-Block. "expires" ist davon nicht betroffen.
+server {
+ listen 80;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Sicherheits-Header (alle Antworten, auch Fehler)
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 1024;
+ gzip_comp_level 6;
+ gzip_types
+ text/plain
+ text/css
+ text/xml
+ text/javascript
+ application/javascript
+ application/json
+ application/manifest+json
+ application/xml
+ image/svg+xml;
+
+ # Gehashte Build-Dateien: ein Jahr, nie auf index.html zurückfallen.
+ location ^~ /assets/ {
+ try_files $uri =404;
+ expires 1y;
+ access_log off;
+ }
+
+ # Die Appwrite-API liegt nur über Traefik (Header X-Appwrite-Project) auf /v1.
+ # Ohne diesen Header darf nginx hier nie die App (index.html) ausliefern.
+ location ^~ /v1/ {
+ return 404;
+ }
+
+ location = /index.html {
+ expires -1;
+ }
+
+ # Versteckte Dateien sperren (außer /.well-known). Steht vor den Regex-Blöcken
+ # darunter, damit z. B. /.git/x.png nicht als Bild durchgeht.
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+
+ location ~* \.(?:png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)$ {
+ expires 7d;
+ }
+
+ # Single-Page-App: alle anderen Pfade (/karten, /editor ...) liefern index.html.
+ location / {
+ try_files $uri $uri/ /index.html;
+ expires -1;
+ }
+}
diff --git a/deploy/traefik/aircard.yml b/deploy/traefik/aircard.yml
new file mode 100644
index 0000000..d1bc0fb
--- /dev/null
+++ b/deploy/traefik/aircard.yml
@@ -0,0 +1,22 @@
+# Appwrite-Route für aircard-ios.org (Projekt "aircard" auf der bestehenden Appwrite-Instanz).
+#
+# Das Frontend ruft https://aircard-ios.org/v1/... auf und sendet den Header
+# X-Appwrite-Project: aircard. Nur Anfragen MIT diesem Header gehen an Appwrite; alles andere
+# unter /v1 (z. B. ein
oder ein Aufruf ohne Header) landet beim nginx der Seite und
+# bekommt dort 404.
+#
+# Priorität 150 liegt über dem Seiten-Router (Host-Regel, Label-Router `aircard`) und unter
+# dem Webhook-Router (200). Der Service `appwrite@docker` stammt aus dem Appwrite-Container;
+# an diesem Container wird nichts geändert.
+#
+# Wird per Datei-Provider geladen (Hot-Reload). Additiv + reversibel: Datei löschen entfernt die Route.
+http:
+ routers:
+ aircard-api:
+ rule: "Host(`aircard-ios.org`) && PathPrefix(`/v1`) && Header(`X-Appwrite-Project`, `aircard`)"
+ entryPoints:
+ - websecure
+ service: appwrite@docker
+ priority: 150
+ tls:
+ certResolver: letsencrypt
diff --git a/deploy/webhook-server.js b/deploy/webhook-server.js
new file mode 100644
index 0000000..d6512c2
--- /dev/null
+++ b/deploy/webhook-server.js
@@ -0,0 +1,233 @@
+'use strict';
+
+/**
+ * Deploy-Webhook für aircard-ios.org (Node 22, nur Bordmittel).
+ *
+ * POST /webhook/deploy Gitea-Push-Webhook; Signatur im Header x-gitea-signature
+ * (HMAC-SHA256 über den Rohbody, Hex ohne Präfix).
+ * 401 Signatur fehlt oder stimmt nicht
+ * 413 Body größer als 1 MB
+ * 200 {"ignored":true} Push auf einen anderen Branch (oder ohne Branch)
+ * 202 {"queued":true} Push auf den Deploy-Branch; der Build läuft im Hintergrund
+ * alles andere 404
+ *
+ * Builds laufen nacheinander: höchstens ein laufender und ein wartender Lauf. Weitere
+ * Anfragen während ein Lauf wartet, werden mit 202 beantwortet, fügen aber keinen
+ * Lauf hinzu (der wartende Lauf holt ohnehin den neuesten Stand).
+ *
+ * Umgebung (nur beim Start als Programm): AIRCARD_WEBHOOK_TOKEN (Pflicht, sonst Exit 1),
+ * AIRCARD_WEBHOOK_PORT (8087), AIRCARD_DEPLOY_BRANCH (main), AIRCARD_DEPLOY_SCRIPT
+ * (/deploy.sh), POLL_INTERVAL_MINUTES (15; 0 schaltet das Polling ab).
+ */
+
+const http = require('node:http');
+const crypto = require('node:crypto');
+const { spawn } = require('node:child_process');
+
+const MAX_BODY_BYTES = 1024 * 1024;
+const WEBHOOK_PATH = '/webhook/deploy';
+const BRANCH_PREFIX = 'refs/heads/';
+
+/**
+ * Prüft die Gitea-Signatur: kleingeschriebenes Hex von HMAC-SHA256 über den Rohbody.
+ * false bei leerem Secret, fehlender oder unpassend langer Signatur.
+ */
+function verifySignature(raw, signatureHex, secret) {
+ if (typeof secret !== 'string' || secret === '') return false;
+ if (typeof signatureHex !== 'string' || signatureHex === '') return false;
+ if (!Buffer.isBuffer(raw)) return false;
+
+ const expected = Buffer.from(crypto.createHmac('sha256', secret).update(raw).digest('hex'));
+ const given = Buffer.from(signatureHex);
+ // timingSafeEqual wirft bei ungleicher Länge, deshalb vorher prüfen.
+ if (given.length !== expected.length) return false;
+ return crypto.timingSafeEqual(given, expected);
+}
+
+/** `refs/heads/` -> ``, alles andere -> null. */
+function branchFromPayload(body) {
+ if (body === null || typeof body !== 'object') return null;
+ const ref = body.ref;
+ if (typeof ref !== 'string' || !ref.startsWith(BRANCH_PREFIX)) return null;
+ const branch = ref.slice(BRANCH_PREFIX.length);
+ return branch === '' ? null : branch;
+}
+
+/**
+ * Baut den HTTP-Server (ohne ihn zu starten).
+ *
+ * @param {object} options
+ * @param {string} options.secret Webhook-Secret; leer -> jede Anfrage bekommt 401
+ * @param {string} [options.branch] Deploy-Branch (Standard: main)
+ * @param {(trigger: string) => Promise} options.runDeploy führt einen Lauf aus
+ * @param {(line: string) => void} [options.log] bekommt fertige Zeilen, die mit [webhook] beginnen
+ * @returns {http.Server} mit zusätzlicher Methode `enqueueDeploy(trigger)` für initial/poll-Läufe
+ * über dieselbe Warteschlange
+ */
+function createServer({ secret, branch = 'main', runDeploy, log = console.log }) {
+ const say = (message) => log(`[webhook] ${new Date().toISOString()} ${message}`);
+
+ // Warteschlange: ein laufender Lauf + ein Platz für einen wartenden Lauf.
+ let running = false;
+ let pending = null; // Trigger des wartenden Laufs oder null
+
+ async function drain() {
+ if (running) return;
+ running = true;
+ try {
+ while (pending !== null) {
+ const trigger = pending;
+ pending = null;
+ say(`starting deployment (trigger=${trigger})`);
+ try {
+ await runDeploy(trigger);
+ say(`deployment finished (trigger=${trigger})`);
+ } catch (err) {
+ say(`deployment failed (trigger=${trigger}): ${err && err.message ? err.message : err}`);
+ }
+ }
+ } finally {
+ running = false;
+ }
+ }
+
+ /** @returns {boolean} true, wenn ein neuer Lauf vorgemerkt wurde; false, wenn schon einer wartet */
+ function enqueueDeploy(trigger) {
+ if (pending !== null) {
+ // Der Webhook baut immer, ein Poll darf überspringen: der wartende Lauf behält den stärkeren Trigger.
+ if (trigger === 'webhook') pending = 'webhook';
+ return false;
+ }
+ pending = trigger;
+ drain();
+ return true;
+ }
+
+ function sendJson(res, status, payload) {
+ const body = JSON.stringify(payload);
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
+ res.end(body);
+ }
+
+ function handleWebhook(req, res) {
+ const chunks = [];
+ let size = 0;
+ let tooLarge = false;
+
+ req.on('data', (chunk) => {
+ if (tooLarge) return; // Rest verwerfen, die Verbindung bleibt sauber
+ size += chunk.length;
+ if (size > MAX_BODY_BYTES) {
+ tooLarge = true;
+ chunks.length = 0;
+ say('rejected request: body too large');
+ sendJson(res, 413, { error: 'payload too large' });
+ return;
+ }
+ chunks.push(chunk);
+ });
+
+ req.on('error', (err) => say(`request error: ${err.message}`));
+
+ req.on('end', () => {
+ if (tooLarge) return;
+ const raw = Buffer.concat(chunks);
+
+ if (!verifySignature(raw, req.headers['x-gitea-signature'], secret)) {
+ say('rejected request: invalid signature');
+ sendJson(res, 401, { error: 'invalid signature' });
+ return;
+ }
+
+ let pushedBranch = null;
+ try {
+ pushedBranch = branchFromPayload(JSON.parse(raw.toString('utf8')));
+ } catch {
+ // kein JSON: wie ein Push ohne Branch behandeln
+ }
+
+ if (pushedBranch !== branch) {
+ say(`ignoring push to ${pushedBranch === null ? 'unknown ref' : pushedBranch} (only ${branch} deploys)`);
+ sendJson(res, 200, { ignored: true });
+ return;
+ }
+
+ // Sofort antworten: Gitea wartet nur wenige Sekunden, der Build dauert Minuten.
+ const queued = enqueueDeploy('webhook');
+ say(queued ? `push to ${branch} queued` : `push to ${branch} accepted, a deployment is already waiting`);
+ sendJson(res, 202, { queued: true });
+ });
+ }
+
+ const server = http.createServer((req, res) => {
+ const pathname = (req.url || '').split('?')[0];
+ if (req.method === 'POST' && pathname === WEBHOOK_PATH) {
+ handleWebhook(req, res);
+ return;
+ }
+ sendJson(res, 404, { error: 'not found' });
+ });
+
+ server.enqueueDeploy = enqueueDeploy;
+ return server;
+}
+
+// ---- Start als Programm -----------------------------------------------------
+
+/**
+ * Startet `sh