Files

401 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: web-hosting-deployment
description: "Nginx Multi-Domain-Hosting mit Cloudflare DNS, Let's Encrypt SSL und Mail-Preservation – Deployment-Workflow für mehrere Domains auf einem Server"
version: 1.0.0
author: Agent A (netcup VPS Deployer)
platforms: [linux]
---
# Web-Hosting Deployment – Multi-Domain-Setup
Richtet mehrere Domains mit name-based virtual hosting auf einem einzelnen nginx-Server ein, inklusive Cloudflare-DNS-Verwaltung per API, Let's-Encrypt-SSL und sicherer MX-Record-Migration.
## Workflow-Übersicht
1. **Server prüfen** → nginx installiert? Ports 80/443 frei? UFW aktiv?
2. **DNS prüfen** → Wo zeigen die Domains hin? Welcher Provider?
3. **nginx VHosts anlegen** → Eine Config pro Domain in `sites-available/`
4. **DNS umstellen** → Per Cloudflare-API oder manuell A-Records auf Server-IP
5. **SSL per certbot** → Let's Encrypt für alle Domains gleichzeitig
6. **Mail schützen** → MX-Records 1:1 spiegeln, niemals proxien
---
## Schritt 1: nginx installieren & Basis-Check
```bash
apt-get update -qq && apt-get install -y nginx certbot python3-certbot-nginx
systemctl enable --now nginx
ufw allow 80/tcp && ufw allow 443/tcp && ufw allow 22/tcp
ufw --force enable
```
**Prüfen:**
```bash
ss -tlnp | grep -E ':80 |:443 ' # Ports frei?
nginx -t # Config ok?
```
---
## Schritt 2: Domain-Verzeichnisse & Platzhalter-Seiten
```bash
# Für jede Domain:
mkdir -p /var/www/example.de/{html,logs}
chown -R www-data:www-data /var/www/example.de
chmod -R 755 /var/www/
```
> ⚠️ **Pitfall: 403 Forbidden** – Ohne `chown www-data:www-data` kann nginx die Dateien nicht lesen. Sofort nach `mkdir` ausführen!
>
> ⚠️ **Pitfall: `write_file` erstellt root-only Dateien!** Hermes' write_file-Tool legt Dateien mit `root:root` und `600`-Permissions an. nginx (www-data) bekommt 403. **Nach JEDEM write_file auf /var/www/ sofort:** `chown www-data:www-data <datei> && chmod 644 <datei>`
---
## Schritt 3: nginx Virtual Hosts (Name-based)
Pro Domain eine Config in `/etc/nginx/sites-available/`:
```nginx
server {
listen 80;
listen [::]:80;
server_name example.de www.example.de *.example.de;
root /var/www/example.de/html;
index index.html index.htm;
access_log /var/www/example.de/logs/access.log;
error_log /var/www/example.de/logs/error.log;
location / {
try_files $uri $uri/ =404;
}
}
```
> ⚠️ **Pitfall: Wildcard-Subdomains** – `*.example.de` in `server_name` nicht vergessen, sonst landen Subdomains auf der nginx-Defaultseite!
Aktivieren:
```bash
ln -sf /etc/nginx/sites-available/example.de /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
```
**Lokal testen (vor DNS-Umstellung):**
```bash
curl -s -o /dev/null -w "HTTP %{http_code}\n" -H "Host: example.de" http://localhost/
curl -s -H "Host: example.de" http://localhost/ | grep -oP '<title>\K[^<]+'
```
---
## Schritt 4: Cloudflare DNS per API verwalten
### Zonen auflisten
```bash
curl -s "https://api.cloudflare.com/client/v4/zones" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" | python3 -c "
import json, sys
for z in json.load(sys.stdin)['result']:
print(f\"{z['name']:35s} | {z['id']}\")
"
```
### Neue Zone anlegen
```bash
curl -s -X POST "https://api.cloudflare.com/client/v4/zones" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"example.de","account":{"id":"ACCOUNT_ID"},"jump_start":false,"type":"full"}'
```
### DNS-Einträge abrufen
```bash
curl -s "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_TOKEN" | python3 -c "
import json, sys
for r in json.load(sys.stdin)['result']:
print(f\"{r['type']:6s} {r['name']:40s} → {r['content']:25s} proxied={r['proxied']}\")
"
```
### A-Record setzen/aktualisieren (NEU)
```bash
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"A","name":"example.de","content":"SERVER_IP","proxied":false,"ttl":1}'
```
### A-Record aktualisieren (BESTEHEND)
```bash
curl -s -X PATCH "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"content":"SERVER_IP","proxied":false,"type":"A","name":"example.de","ttl":1}'
```
> ⚠️ **Pitfall: Cloudflare-Proxy (orangene Wolke)** – `"proxied":true` versteckt die echte Server-IP hinter Cloudflare. Das bricht:
> - **Let's Encrypt HTTP-Challenges** (Zertifikat kann nicht validiert werden)
> - **Direkte Server-Erreichbarkeit** für nicht-HTTP-Dienste
> - **Mail-Server** (niemals MX-Records proxien!)
>
> **Regel:** Für Webserver, die Let's Encrypt nutzen: `"proxied":false`. Nach SSL-Erhalt kann Proxy optional wieder aktiviert werden (dann aber TLS-Mode auf "Full (strict)").
---
## Schritt 5: Let's Encrypt SSL
### Alle Domains gleichzeitig (ein Zertifikat)
```bash
certbot --nginx \
-d domain1.de,www.domain1.de \
-d domain2.de,www.domain2.de \
--non-interactive --agree-tos \
--email admin@domain1.de \
--redirect
```
> ⚠️ **Pitfall: CNAME/Proxy blockiert Challenge** – Wenn certbot mit `unauthorized` fehlschlägt:
> 1. Prüfen ob CNAME-Records `proxied=true` haben → auf `false` setzen
> 2. Sicherstellen dass ALLE Domains im Zertifikat auf den Server zeigen
> 3. `dig +short A www.example.de` muss die Server-IP liefern, NICHT Cloudflare/GitHub-IPs
**Auto-Renewal prüfen:**
```bash
systemctl status certbot.timer
certbot renew --dry-run
```
---
## Schritt 6: MX-Records bei DNS-Migration schützen
**Goldene Regel:** MX-Records **niemals verändern**, nur **1:1 spiegeln**.
### Vor der Migration: Aktuelle MX-Daten sichern
```bash
dig +short MX example.de # MX-Records
dig +short A mail.example.de # Mailserver-IP
```
### In Cloudflare exakt spiegeln
```bash
# Mailserver A-Record (WICHTIG: proxied=false!)
curl -s -X POST ".../dns_records" -d '{
"type":"A","name":"mail.example.de",
"content":"MAIL_SERVER_IP","proxied":false,"ttl":1
}'
# MX-Record (darf NIE geproxied werden)
curl -s -X POST ".../dns_records" -d '{
"type":"MX","name":"example.de",
"content":"mail.example.de","priority":10,
"proxied":false,"ttl":1
}'
```
> ⚠️ **Pitfall: Mail-Ausfall** – MX-Records auch nur kurzzeitig zu verlieren bedeutet E-Mail-Bounce. Immer VOR dem Nameserver-Umzug sicherstellen, dass MX + Mail-A-Records in der neuen Zone existieren.
---
## Nameserver-Umzug: Schritt-für-Schritt
1. Zone bei Cloudflare anlegen
2. **ALLE bestehenden DNS-Einträge spiegeln** (A, AAAA, MX, CNAME, TXT, SRV)
3. MX & Mail-A-Records doppelt prüfen
4. Cloudflare-Nameserver notieren (z.B. `jay.ns.cloudflare.com`, `perla.ns.cloudflare.com`)
5. Beim alten Provider Nameserver ändern
6. 1–48h auf Propagation warten
7. SSL per certbot einrichten
---
## Troubleshooting
### 403 Forbidden nach VHost-Erstellung
```bash
chown -R www-data:www-data /var/www/
chmod -R 755 /var/www/
```
### Wildcard-Subdomains zeigen Default-Seite
`server_name` muss `*.example.de` enthalten. Prüfen:
```bash
grep server_name /etc/nginx/sites-enabled/example.de
curl -sI -H "Host: test.example.de" http://localhost/
```
### certbot: unauthorized / Invalid response
- Alle Domains im Zertifikat auf `proxied=false` prüfen
- `www`-CNAME darf nicht auf GitHub Pages o.ä. zeigen
- Sicherstellen dass ALLE Domains auf die Server-IP auflösen
### ⚠️ PITFALL – Certbot überschreibt SSL-Pfade in BESTEHENDEN vhosts
Siehe `nginx-vhost-deployment/SKILL.md` → **PITFALL – Certbot überschreibt SSL-Pfade in BESTEHENDEN vhosts**.
Kurzfassung: Nach jedem `certbot --nginx -d NEUE.DOMAIN.de` prüfen ob alle bestehenden Domains
noch ihr RICHTIGES Zertifikat haben. Certbot ändert ggf. `ssl_certificate`-Pfade in anderen
vhost-Dateien. Test: `curl -sk https://alte-domain.de/` → HTTP 200? Wenn nicht: SSL-Pfade
in der betroffenen Config zurücksetzen.
### ERR_CERT_COMMON_NAME_INVALID: vhost nutzt fremdes Zertifikat (2026-08-04)
**Symptom:** `curl https://<sub>...` ohne `-k` schlägt fehl; Browser meldet `ERR_CERT_COMMON_NAME_INVALID`, auch wenn eine frühere `curl -k`-Probe 200 lieferte. Grund: Der Subdomain-vhost referenziert ein **anderes** Zertifikat, in dessen SANs der eigene Hostname **fehlt**.
**Diagnose (immer zuerst, dauert 2 Sekunden):**
```bash
# Welches Zert referenziert der vhost?
grep -iE "ssl_certificate" /etc/nginx/sites-available/<sub>.perfekt-ohne-en.de
# Welche Hostnames deckt DIESES Zert ab?
openssl x509 -in /etc/letsencrypt/live/<cert>/fullchain.pem -noout -text \
| grep -A1 "Subject Alternative Name"
```
→ `tictactoe.perfekt-ohne-en.de` war auf das `perfekt-ohne-en.de`-Zert gelinkt (San der `fitness.`/`clownfischserver.` etc.), `tictactoe.` fehlte → Browser-Warnung.
**Fix: eigenes Zertifikat NUR für die Subdomain anlegen** (statt `--expand` aufs alte Zert):
```bash
certbot --nginx -d <sub>.perfekt-ohne-en.de \
--non-interactive --agree-tos -m <mail> --redirect
```
Erzeugt `/etc/letsencrypt/live/<sub>.perfekt-ohne-en.de/`, deployt automatisch in den vhost, Auto-Renew läuft. Danach ohne `-k` verifizieren:
```bash
curl -s -o /dev/null -w "HTTP %{http_code}\n" https://<sub>.perfekt-ohne-en.de/
openssl x509 -in /etc/letsencrypt/live/<sub>.perfekt-ohne-en.de/fullchain.pem -noout -text \
| grep -A1 "Alternative"
```
> ⚠️ **Warum NICHT `certbot --expand` aufs alte Zert?** Das alte Zert trägt ggf. Hostnames inaktiver/archivierter vhosts (z.B. eingestampfte Sites). `--expand` muss alle aufgelisteten Domains erneut validieren — scheitert leicht an deaktivierten vhosts und "budelt" in Sachen, die ruhen sollen. Ein frisches, schlankes Subdomain-Zert ist sauberer und kapselt die Domain. Faustregel: **ein Ziel-Hostname → eigenes Zert; nur verwandte aktive Hostnames in ein Zert bündeln.**
### Cloudflare API: Invalid API Token
- Token ist ggf. Zonen-beschränkt → kann keine neuen Zonen erstellen
- `/user/tokens/verify` funktioniert nur mit Account-weitem Token
- Zonen-Operationen (DNS) funktionieren trotzdem mit Zonen-Token
### Neue Subdomain: certbot --nginx scheitert, wenn SSL-vhost schon auf fehlendes Zert zeigt
**Situation:** Für eine neue Subdomain legt man einen vhost an und schreibt die
`ssl_certificate`-Zeilen **sofort** (aus Vorlage) hinein. `nginx -t` schlägt dann
fehl: `cannot load certificate "…/live/<sub>/fullchain.pem": No such file` — und
weil `nginx -t` bricht, wirft **`certbot --nginx` denselben Config-Fehler** (MisconfigurationError), bekommt kein Zert und es geht nicht weiter.
**Lösung (Reihenfolge zählt):**
1. vhost-Backup: `cp sites-available/<sub>.… sites-available/<sub>.…bak`
2. vhost auf **reines HTTP-80** ohne jede `ssl_certificate`-Zeile ersetzen →
`nginx -t` wieder ok → `systemctl reload nginx`
3. Zert **per webroot-Challenge** holen (KEIN `--nginx`-Plugin, das braucht eine
ladende Config):
```bash
certbot certonly --webroot -w /var/www/<sub> -d <sub>.perfekt-ohne-en.de \
--non-interactive --agree-tos --register-unsafely-without-email
```
4. SSL-vhost (aus dem `.bak`) zurückspielen → `nginx -t` + reload
5. Mit `curl -s -o /dev/null -w "%{http_code}" https://<sub>/` verifizieren
(HTTP 000 + Exit 60 = nur curl-Zert-Warnung, kein Serverfehler; `-k` zeigt dann 200).
### Statische datengetriebene Website mit PDF-Download je Eintrag
Für JSON-datengetriebene Listen-Seiten mit per-Eintrag-PDF (wkhtmltopdf) →
**`references/static-data-site-pdf.md`** (Datei-Struktur, Slug-Helper, PDF-Generator-Skript, Verifikation).
### Mehrstufige (Drill-Down) datengetriebene Website: Flask + SQLite + Reverse-Proxy
Für Sites mit verschachtelter Navigation über mehrere Ebenen (URL `/2/<stadt>`,
Klick auf ein Ziel erzeugt die nächste Liste) → **nicht statisch bauen**, sondern
**Flask + SQLite hinter nginx-Reverse-Proxy** (sonst ~1000–2000 statische Dateien).
Deckung: systemd-Service, Slug↔Umlaut-Match, PDF-on-demand (**je Einzel-Verbindung
`von→nach`, nicht Liste** — Nutzererwartung 2026-08-09), `/healthz`. Details →
**`references/flask-sqlite-drilldown-site.md`**.
### Docker-Interne-Dienste deployen (Gitea, Vaultwarden)
Für Docker-basierte Web-Apps (Gitea, Vaultwarden, Passwort-Manager, Git-Server)
hinter nginx-Reverse-Proxy mit Let's Encrypt SSL:
**`references/docker-internal-services.md`** — Deployment-Workflow, Setup-Schritte,
Admin-Token-Sicherung, Registrierung öffnen/schließen, Mirror auf zweitem Server.
### Echte Fahrplan-/Zugdaten automatisieren: DB blockt Bots
Für Reise-/Verbindungs-Sites mit realen deutschen Nahverkehrs-Zeiten: bahn.de +
DB-API blocken den Server-Zugriff hart (Bot-Erkennung Fehler 753, `OPS_BLOCKED`),
VRR-EFA deckt nur den eigenen Verbund, andere GTFS-Endpunkte waren unerreichbar.
Funktionierende Wege: regionale EFA, Nutzer liefert echte Zeiten manuell, oder
klar-labelierte Schätzwerte ("vor Fahrt auf bahn.de prüfen"). Nie erfundene Zeiten
als real ausgeben. Details → **`references/deutsche-bahn-timetable-data.md`**.
### Subdomain mit Wildcard-DNS schnell deployen
Wenn die Hauptdomain bereits per Wildcard (`*.example.de`) auf den Server zeigt, braucht eine neue Subdomain KEINE DNS-Änderung:
```bash
# 1. Verzeichnis + nginx-Config
mkdir -p /var/www/sub.example.de/{html,logs}
chown -R www-data:www-data /var/www/sub.example.de
cat > /etc/nginx/sites-available/sub.example.de << 'NGINX'
server {
server_name sub.example.de;
root /var/www/sub.example.de/html;
index index.html;
access_log /var/www/sub.example.de/logs/access.log;
error_log /var/www/sub.example.de/logs/error.log;
location / { try_files $uri $uri/ =404; }
listen 80; listen [::]:80;
}
NGINX
ln -sf /etc/nginx/sites-available/sub.example.de /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
# 2. HTML erstellen + Permissions fixen (CRITICAL!)
# ...write_file oder echo in index.html...
chown www-data:www-data /var/www/sub.example.de/html/index.html
chmod 644 /var/www/sub.example.de/html/index.html
# 3. SSL
certbot --nginx -d sub.example.de --non-interactive --agree-tos \
--email admin@example.de --redirect
```
Lokal testen: `curl -s -H "Host: sub.example.de" http://localhost/ | grep '<title>'`
### Multi-Site-Pattern: Verlinkte Websites mit Datenübergabe
Für mehrere zusammenhängende Single-Page-Apps auf Subdomains (z.B. Fitness-Rechner + Kalorienrechner) mit Cross-Linking, URL-Parameter-Datenübergabe, PDF-Export und Sponsor-Badge → **`references/multi-site-pattern.md`**
### Site einstampfen / deaktivieren / archivieren (2026-08-04)
Nutzer-Anweisung „einstampfen, ins Archiv, ruhen lassen" → **deaktivieren, nicht zerstören** (nicht-destruktive Grundregel):
```bash
# 1. nginx vhost deaktivieren (Symlink raus; available bleibt als Sicherung)
rm /etc/nginx/sites-enabled/<domain>
nginx -t && systemctl reload nginx
# 2. Code ins Archiv verschieben (nicht löschen!)
mkdir -p /srv/work/archiv/<projekt>
mv /root/<projekt> /srv/work/archiv/<projekt>/repo
mv /root/<projekt>_extracted /srv/work/archiv/<projekt>/extracted # falls vorhanden
# 3. LIESMICH.md mit Repo-Link + Reaktivierungsweg ablegen
# (Status, GitHub-URL, wie wieder aktivieren: Symlink + Code zurück)
```
- **Keinen Symlink/Code löschen** — nur deaktivieren + ins Archiv legen. Reaktivierung = Symlink zurück + Code zurück.
- **GitHub-Repo bleibt unangetastet** (extern); nur lokale Deployment-Artefakte werden archiviert. Repo-Link in `LIESMICH.md` festhalten.
- Nach Abschluss: `mesh-log.sh`-Eintrag + **Memory aktualisieren** (Projektstatus ändert sich von „läuft" zu „eingestampft/archiviert") — sonst halluziniert ein späterer Session-Start fälschlich „läuft noch stabil".
### Autogenerierte Report-/Archiv-Websites (Cron-regeneriertes HTML, 2026-08-03)
Wenn eine Subdomain **automatisch generierte Inhalte** zeigt (Live-Panel, Tages-Archive), die ein Script periodisch neu schreibt:
- **Kein PHP nötig.** Ein Shell-Script (z.B. `duo-master-report.sh`) baut die komplette `index.html` mit einem Heredoc zusammen und läuft per Cron (z.B. alle 60s für Live-Panel, täglich 23:50 für Tagesarchiv).
- Struktur: `Live-Panel` (dokumentationen.*) zeigt aktuelle Zustände; `Archiv` (archiv.*) speichert pro Datum eine HTML-Seite `JJJJ-MM-TT.html` PLUS eine `index.html` mit Linkliste auf alle Archive.
- Nach jedem Write: `chown www-data:www-data <webroot>` — sonst 403 (Hermes write_file erzeugt root:root).
- Daily-Archiv-Name = ISO-Datum (`$(date +%Y-%m-%d).html`), Linkliste wird aus vorhandenen `.html`-Dateien (ausgenommen `index.html`) aufgebaut.
- ⚠️ **Bash-Pitfall unter `set -euo pipefail`:** `ls /pfad/*.json | wc -l` bricht mit Exit 2 ab, wenn keine Dateien matchen (leerer Glob). Vorher `shopt -s nullglob` setzen (und danach `shopt -u nullglob`), sonst endet das gesamte Skript scheinbar ohne Fehler. Gleiches gilt für `ls "$WEB"/*.html` in der Linkliste.