Files

17 KiB
Raw Permalink Blame History

name, description, version, author, platforms
name description version author platforms
web-hosting-deployment Nginx Multi-Domain-Hosting mit Cloudflare DNS, Let's Encrypt SSL und Mail-Preservation – Deployment-Workflow für mehrere Domains auf einem Server 1.0.0 Agent A (netcup VPS Deployer)
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

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:

ss -tlnp | grep -E ':80 |:443 '    # Ports frei?
nginx -t                             # Config ok?

Schritt 2: Domain-Verzeichnisse & Platzhalter-Seiten

# 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/:

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:

ln -sf /etc/nginx/sites-available/example.de /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

Lokal testen (vor DNS-Umstellung):

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

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

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

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)

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)

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)

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:

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

dig +short MX example.de          # MX-Records
dig +short A mail.example.de      # Mailserver-IP

In Cloudflare exakt spiegeln

# 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

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:

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):

# 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):

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:

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):
    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:

# 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):

# 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.