Zero-Downtime-Produktionswebsite mit Docker, Caddy v2 & GitHub Actions auf Netcup bereitstellen
Das Bereitstellen einer produktiven Webanwendung auf einem Netcup VPS oder Root Server erfordert weit mehr als das bloße Freigeben von Port 80 auf einem einzelnen Container. Eine hochverfügbare Produktionsarchitektur terminiert TLS 1.3 und HTTP/3 (QUIC) an einem gehärteten Edge-Reverse-Proxy, isoliert PostgreSQL und Redis in einem vom Internet getrennten internen Netzwerk, optimiert die UDP-Socket-Puffer des Linux-Kernels für schnelle QUIC-Übertragungen und führt bei neuen Commits auf main unterbrechungsfreie Rolling Updates (Zero Downtime) durch. Dieses End-to-End-Engineering-Tutorial baut diesen kompletten Stack mit Docker Compose v2, Caddy v2, PostgreSQL 16, Redis 7 und einer automatisierten GitHub-Actions-SSH-Deployment-Pipeline auf.
AS197540). VORAUSSETZUNGEN // ANFORDERUNGEN
CHECKLISTE- ✓ Ein Netcup VPS (z. B. VPS 1000 G12.5) oder Root Server (z. B. RS 1000 G12.5) mit Ubuntu 24.04 LTS oder Debian 12 und installiertem Docker Compose v2
- ✓ Eine registrierte Domain mit DNS-
A- (IPv4) undAAAA-Einträgen (IPv6), die auf Ihren Netcup-Server zeigen - ✓ Freigeschaltete Ports
80/tcp,443/tcpund443/udpsowohl in UFW als auch in der Netcup SCP Firewall
Wichtige Erkenntnisse (Key Takeaways)
KURZFAZIT-
01 Veröffentlichen Sie ausschließlich die Ports
80/tcp,443/tcpund443/udpam Caddy-Edge-Container; halten Sie Anwendungs-, PostgreSQL- und Redis-Container strikt von den öffentlichen Netzwerkschnittstellen des Hosts fern. -
02 Erhöhen Sie die Empfangs- und Sendepuffer für UDP-Sockets im Linux-Kernel (
net.core.rmem_max = 7500000) persysctl, damit derquic-go-Stack von Caddy HTTP/3-Traffic mit voller 1–2,5 Gbit/s Netcup-Leitungsgeschwindigkeit ohne Pufferverluste ausliefern kann. -
03 Konfigurieren Sie aktive Upstream-Healthchecks (
health_uri /healthz) und Wiederholungszeitfenster (lb_try_duration 10s) in derCaddyfile, um während Container-Rollouts null verlorene Requests zu garantieren. -
04 Optimieren Sie die PostgreSQL-16-Parameter (
shared_buffers,effective_cache_size,random_page_cost=1.1) gezielt für das lokale RAID10-NVMe-Speichersubsystem von Netcup. -
05 Automatisieren Sie Deployments über GitHub Actions mithilfe eines dedizierten
deploy-Benutzers nach dem Least-Privilege-Prinzip und eines Rollout-Skripts, das alte Container erst nach erfolgreichem Healthcheck beendet.
📑 Inhaltsverzeichnis (5 Abschnitte) ▼
- 01 Schritt 1: Dual-Stack-DNS-Einträge prüfen & Kernel-Puffer für HTTP/3 QUIC optimieren
- 02 Schritt 2: Mehrstufige Produktions-compose.yaml erstellen (Caddy + App + Postgres 16 + Redis 7)
- 03 Schritt 3: Caddyfile mit aktiven Healthchecks, HTTP/3 & Security-Headern konfigurieren
- 04 Schritt 4: PostgreSQL 16 (postgresql.conf) für Netcup RAID10 NVMe-Storage optimieren
- 05 Schritt 5: Zero-Downtime-Rollout-Skript & GitHub Actions SSH-Deployment-Pipeline
Schritt 1: Dual-Stack-DNS-Einträge prüfen & Kernel-Puffer für HTTP/3 QUIC optimieren
Bevor Sie einen ACME-TLS-Reverse-Proxy wie Caddy v2 oder Traefik v3 starten, müssen Sie sicherstellen, dass sowohl Ihr A- (IPv4) als auch Ihr AAAA-DNS-Eintrag (IPv6) exakt auf Ihren Netcup-Server auflösen. Zertifizierungsstellen wie Let’s Encrypt und ZeroSSL bevorzugen bei der Validierung standardmäßig IPv6 (AAAA), sobald ein solcher Eintrag existiert. Zeigt Ihr AAAA-Record auf einen Tippfehler oder eine nicht konfigurierte IPv6-Adresse Ihrer Netcup-Netzwerkschnittstelle, laufen ACME-HTTP-01- und TLS-ALPN-01-Challenges in einen Timeout – selbst wenn IPv4 fehlerfrei funktioniert.
Beim ersten Start durchläuft Caddy v2 vollautomatisch den ACME-Lebenszyklus: Es erzeugt einen ECDSA-Account-Key, löst wahlweise die HTTP-01-Challenge auf Port 80/tcp oder die TLS-ALPN-01-Challenge direkt über Port 443/tcp (mittels acme-tls/1-ALPN-Extension), speichert das ausgestellte X.509-Zertifikat im persistenten Volume caddy_data (/data/caddy/certificates) und erneuert das Zertifikat automatisch 30 Tage vor Ablauf im Hintergrund. Zudem ruft Caddy selbstständig OCSP-Stapling-Antworten ab, sodass Browser beim TLS-Handshake keine externen Sperrlisten-Abfragen durchführen müssen.
Zudem basiert HTTP/3 auf dem QUIC-Protokoll über UDP-Port 443. Standard-Kernel von Ubuntu und Debian werden mit einem extrem kleinen UDP-Empfangspuffer ausgeliefert (rmem_max = 212992 Bytes). Bei gleichzeitigen HTTP/3-Seitenaufrufen auf einer 1 Gbit/s oder 2,5 Gbit/s schnellen Netcup-Anbindung protokolliert Caddy ohne Kernel-Anpassung Warnungen (failed to sufficiently increase receive buffer size) und fällt auf langsamere Übertragungsfenster zurück.
# 1. Vor dem Start von Caddy die A- und AAAA-Einträge gegen einen externen Resolver prüfen
dig +short A app.example.com @1.1.1.1
dig +short AAAA app.example.com @1.1.1.1
# 2. UDP-Socket-Puffer für HTTP/3 (QUIC) vergrößern und TCP-BBR-Staukontrolle aktivieren
sudo tee /etc/sysctl.d/99-netcup-web-tuning.conf > /dev/null <<'EOF'
# UDP-Empfangs- und Sendepuffer auf ~7,5 MB für hohen HTTP/3-QUIC-Durchsatz erhöhen
net.core.rmem_max = 7500000
net.core.wmem_max = 7500000
net.core.rmem_default = 1048576
net.core.wmem_default = 1048576
# BBR-Staukontrolle aktivieren & Listen-Backlog für Lastspitzen anheben
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
net.core.somaxconn = 8192
net.ipv4.tcp_max_syn_backlog = 8192
EOF
sudo sysctl --system
# 3. HTTP, HTTPS (TCP) und HTTP/3 QUIC (UDP 443) in UFW freigeben
sudo ufw allow 80/tcp comment 'HTTP ACME & Redirect'
sudo ufw allow 443/tcp comment 'HTTPS TLS 1.3'
sudo ufw allow 443/udp comment 'HTTP/3 QUIC'
sudo ufw status numbered Befehlserklärung: Überprüft die öffentliche IPv4/IPv6-DNS-Propagierung, wendet Kernel-UDP-Puffer- und TCP-BBR-Optimierungen für schnelle HTTP/3-Auslieferung an und öffnet TCP 80/443 sowie UDP 443 in UFW.
* Applying /etc/sysctl.d/99-netcup-web-tuning.conf ...
net.core.rmem_max = 7500000
net.ipv4.tcp_congestion_control = bbr Während Traefik v3 seine Stärken bei der dynamischen Docker-Label-Erkennung in großen Multi-Tenant-Clustern ausspielt, ist Caddy v2 für Single-Node-Deployments auf Netcup VPS und Root Servern meist die überlegene Wahl: Caddy benötigt nur ~35 MB RAM, nutzt eine übersichtliche Caddyfile-Syntax, beherrscht dynamische Zstandard-(zstd)-Komprimierung sowie automatisches OCSP-Stapling und kommt vor allem ohne das Einbinden des hochgradig sicherheitskritischen /var/run/docker.sock-Sockets aus.
Schritt 2: Mehrstufige Produktions-compose.yaml erstellen (Caddy + App + Postgres 16 + Redis 7)
Wir erstellen unseren produktiven Web-Stack unter /opt/stacks/prod-web. Die Architektur trennt den Datenverkehr strikt in ein edge_net (verbindet Caddy mit den Anwendungscontainern) und ein abgeschottetes db_net (internal: true, verbindet die Anwendungscontainer mit PostgreSQL 16 und Redis 7).
Die Service-Erkennung zwischen diesen beiden getrennten Netzwerken übernimmt der eingebettete Docker-DNS-Server unter 127.0.0.11. Da der Dienst app als einziger Container sowohl an edge_net als auch an db_net angeschlossen ist, fungiert er als kontrolliertes Anwendungs-Gateway: Caddy im edge_net kann den Namen app auflösen, erhält für postgres oder redis jedoch NXDOMAIN und besitzt keinerlei IP-Route in das db_net.
Beachten Sie außerdem, dass für den Dienst app weder ein fester container_name noch Host-ports: deklariert werden. Durch den Verzicht auf feste Containernamen und Host-Ports können wir app bei Zero-Downtime-Deployments horizontal auf zwei gleichzeitige Instanzen skalieren (--scale app=2). Der interne Docker-DNS-Server liefert dann für den Hostnamen app beide Container-IP-Adressen im Round-Robin-Verfahren an Caddy zurück.
sudo mkdir -p /opt/stacks/prod-web/{caddy,postgres,secrets,logs}
sudo chmod 0700 /opt/stacks/prod-web/secrets
openssl rand -base64 32 | tr -d '\n' | sudo tee /opt/stacks/prod-web/secrets/db_password.txt > /dev/null
sudo chmod 0400 /opt/stacks/prod-web/secrets/db_password.txt
sudo tee /opt/stacks/prod-web/.env > /dev/null <<'EOF'
COMPOSE_PROJECT_NAME=prod_web
DOMAIN_NAME=app.example.com
ACME_EMAIL=ops@example.com
APP_IMAGE=ghcr.io/your-org/production-web:2026.11.1
POSTGRES_DB=prod_app
POSTGRES_USER=prod_user
EOF
sudo chmod 0600 /opt/stacks/prod-web/.env Befehlserklärung: Erstellt die Verzeichnisstruktur, generiert eine 256-Bit-Passwortdatei für die Datenbank und befüllt die .env-Datei mit Domain- und Image-Release-Parametern.
name: ${COMPOSE_PROJECT_NAME:-prod_web}
x-logging: &default-logging
driver: "json-file"
options:
max-size: "20m"
max-file: "5"
compress: "true"
services:
caddy:
image: caddy:2.8-alpine
container_name: prod_web_caddy
restart: unless-stopped
ports:
- "80:80/tcp"
- "443:443/tcp"
- "443:443/udp"
environment:
DOMAIN_NAME: ${DOMAIN_NAME}
ACME_EMAIL: ${ACME_EMAIL}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
- ./logs:/var/log/caddy
networks:
- edge_net
depends_on:
app:
condition: service_healthy
deploy:
resources:
limits:
cpus: "0.75"
memory: 384M
security_opt:
- no-new-privileges:true
logging: *default-logging
app:
image: ${APP_IMAGE}
restart: unless-stopped
expose:
- "3000"
environment:
NODE_ENV: production
PORT: "3000"
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: ${POSTGRES_DB}
DB_USER: ${POSTGRES_USER}
DB_PASSWORD_FILE: /run/secrets/db_password
REDIS_URL: redis://redis:6379/0
secrets:
- db_password
networks:
- edge_net
- db_net
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/healthz > /dev/null || exit 1"]
interval: 10s
timeout: 3s
retries: 3
start_period: 15s
deploy:
resources:
limits:
cpus: "1.50"
memory: 1536M
security_opt:
- no-new-privileges:true
logging: *default-logging
postgres:
image: postgres:16.4-alpine3.20
container_name: prod_web_postgres
restart: unless-stopped
shm_size: 256mb
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- pg_data:/var/lib/postgresql/data
- ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro
command: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"]
networks:
- db_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB} -h 127.0.0.1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
deploy:
resources:
limits:
cpus: "1.50"
memory: 3072M
security_opt:
- no-new-privileges:true
logging: *default-logging
redis:
image: redis:7.4-alpine
container_name: prod_web_redis
restart: unless-stopped
command:
- "redis-server"
- "--appendonly"
- "yes"
- "--maxmemory"
- "512mb"
- "--maxmemory-policy"
- "allkeys-lru"
volumes:
- redis_data:/data
networks:
- db_net
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
deploy:
resources:
limits:
cpus: "0.50"
memory: 768M
security_opt:
- no-new-privileges:true
logging: *default-logging
secrets:
db_password:
file: ./secrets/db_password.txt
volumes:
caddy_data:
caddy_config:
pg_data:
redis_data:
networks:
edge_net:
driver: bridge
db_net:
driver: bridge
internal: true Befehlserklärung: Vollständige Produktions-Compose-Datei, die Postgres 16 und Redis 7 im db_net isoliert und gleichzeitig die horizontale Skalierung von app hinter caddy im edge_net ermöglicht.
SICHERHEITSHINWEIS: Löschen Sie bei regulären Deployments niemals das Named Volume caddy_data; dort sind Ihre ausgestellten ACME-Zertifikate und Account-Keys gespeichert. Ein wiederholtes Löschen und Neuerstellen führt unweigerlich in das Rate-Limit von Let’s Encrypt (maximal 5 identische Zertifikate pro 168 Stunden).
Schritt 3: Caddyfile mit aktiven Healthchecks, HTTP/3 & Security-Headern konfigurieren
Um sicherzustellen, dass während eines Deployments kein einziger Request verloren geht, konfigurieren wir den reverse_proxy-Block von Caddy mit aktiven Healthchecks (health_uri /healthz) und einem Wiederholungszeitfenster (lb_try_duration 10s). Sobald Sie einen neuen app-Container hochskalieren und den alten beenden, leitet Caddy laufende HTTP-Anfragen nahtlos an die gesunde Container-Instanz weiter.
Die Kombination aus aktiver Hintergrund-Überwachung (health_interval 5s) und passiver Fehlererkennung (fail_duration 15s, max_fails 2) deckt auch den kurzen Übergangsmoment ab, in dem der alte Container ein SIGTERM-Signal empfängt. Trifft ein Besucher-Request exakt in dieser Millisekunde auf das schließende Socket des alten Containers, fängt Caddy den Verbindungsfehler ab, wartet gemäß lb_try_interval 250ms kurz ab und wiederholt denselben Request transparent gegen den neuen, bereits gesunden Container – ohne dass der Browser jemals einen 502-Fehler sieht.
{
email {$ACME_EMAIL}
servers {
protocols h1 h2 h3
trusted_proxies static private_ranges
}
}
www.{$DOMAIN_NAME} {
redir https://{$DOMAIN_NAME}{uri} permanent
}
{$DOMAIN_NAME} {
encode zstd gzip
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "accelerometer=(), camera=(), geolocation=(), microphone=(), payment=(), usb=()"
-Server
}
reverse_proxy app:3000 {
lb_policy round_robin
lb_try_duration 10s
lb_try_interval 250ms
health_uri /healthz
health_interval 5s
health_timeout 2s
health_status 200
fail_duration 15s
max_fails 2
header_up X-Real-IP {remote_host}
}
log {
output file /var/log/caddy/access.log {
roll_size 50MiB
roll_keep 5
roll_keep_for 720h
}
format json
}
} Befehlserklärung: Konfiguriert Caddy v2 mit den Protokollen HTTP/1.1, HTTP/2 und HTTP/3, kanonischer www-Weiterleitung, Zstandard/Gzip-Komprimierung, gehärteten OWASP-Security-Headern, aktiven /healthz-Load-Balancer-Probes und rotierten JSON-Access-Logs.
Schritt 4: PostgreSQL 16 (postgresql.conf) für Netcup RAID10 NVMe-Storage optimieren
In der Standardkonfiguration geht PostgreSQL 16 davon aus, dass es auf einer langsamen Magnetfestplatte läuft (random_page_cost = 4.0, shared_buffers = 128MB, effective_io_concurrency = 1). Auf einem Netcup VPS 1000 G12.5 oder RS 1000 G12.5 mit schnellem Enterprise-NVMe-Speicher und 8 GB RAM führen diese Standardwerte dazu, dass der Query Planner langsame sequenzielle Tabellenscans gegenüber schnellen B-Tree-Index-Scans bevorzugt.
Bei der Dimensionierung von shared_buffers innerhalb eines auf 3 GB RAM begrenzten Datenbank-Containers sollten Sie die doppelte Pufferung (Double Buffering) zwischen dem internen Postgres-Pufferpool und dem Linux-Kernel-Page-Cache berücksichtigen: Ein Wert von 25 % des Container-Limits (768MB) zusammen mit effective_cache_size = 2048MB verhindert übermäßigen Speicherdruck. Gleichzeitig informiert random_page_cost = 1.1 den Kostenplaner darüber, dass zufällige 8-KiB-Seitenzugriffe auf den lokalen RAID10-NVMe-SSDs von Netcup nahezu genauso schnell sind wie sequenzielle Lesevorgänge, während effective_io_concurrency = 200 paralleles POSIX-posix_fadvise-Prefetching bei Bitmap-Scans aktiviert.
Erstellen Sie /opt/stacks/prod-web/postgres/postgresql.conf außerdem mit aktiviertem wal_compression = zstd und checkpoint_completion_target = 0.9, um das Schreibvolumen des Write-Ahead-Logs (WAL) zu komprimieren und Checkpoint-Schreibspitzen gleichmäßig zu glätten:
| Parameter | Standardwert Postgres | VPS 1000 G12.5 / RS 1000 G12.5 (8 GB RAM) | RS 2000 G12.5 (16 GB RAM) |
|---|---|---|---|
shared_buffers | 128MB | 768MB (25 % des 3-GB-Containers) | 1536MB (25 % des 6-GB-Containers) |
effective_cache_size | 4GB | 2048MB | 4096MB |
work_mem | 4MB | 16MB | 32MB |
maintenance_work_mem | 64MB | 192MB | 384MB |
random_page_cost | 4.0 (Rotierende HDD) | 1.1 (Enterprise NVMe) | 1.1 (Enterprise NVMe) |
effective_io_concurrency | 1 | 200 | 200 |
# Verbindungen & Authentifizierung
listen_addresses = '*'
max_connections = 100
password_encryption = scram-sha-256
# Speicherzuweisung (Abgestimmt auf einen 3-GB-Postgres-Container auf 8-GB-Netcup-Knoten)
shared_buffers = 768MB
effective_cache_size = 2048MB
work_mem = 16MB
maintenance_work_mem = 192MB
huge_pages = try
# Netcup Enterprise NVMe I/O & Query-Planner-Kostenmodell
random_page_cost = 1.1
seq_page_cost = 1.0
effective_io_concurrency = 200
# Write-Ahead Log (WAL) & Checkpoints
wal_level = replica
max_wal_size = 2GB
min_wal_size = 512MB
checkpoint_completion_target = 0.9
wal_compression = zstd
# Observability & Slow-Query-Protokollierung
logging_collector = off
log_min_duration_statement = 200
log_checkpoints = on
log_lock_waits = on Befehlserklärung: Optimiert PostgreSQL 16 für Netcup NVMe-Speicher mit SCRAM-SHA-256-Authentifizierung, Zstd-WAL-Komprimierung, random_page_cost = 1.1 und automatischem Slow-Query-Logging (>200ms).
Schritt 5: Zero-Downtime-Rollout-Skript & GitHub Actions SSH-Deployment-Pipeline
Wenn Sie Anwendungs-Updates über docker compose down && docker compose up -d einspielen, erhalten Ihre Besucher mehrere Sekunden lang 502 Bad Gateway-Fehlermeldungen, während der Container initialisiert wird. Erstellen Sie stattdessen ein Rolling-Update-Skript (/opt/stacks/prod-web/rollout.sh), das den Dienst app kurzzeitig auf 2 Instanzen skaliert, auf den erfolgreichen /healthz-Check des neuen Containers wartet und anschließend den alten Container sauber beendet.
Beim Beenden des alten Containers (docker stop -t 15) sendet Docker zunächst SIGTERM an Prozess PID 1 im Container und gewährt Ihrer Anwendung ein 15-sekündiges Zeitfenster, um laufende Datenbanktransaktionen abzuschließen und offene HTTP-Keep-Alive-Verbindungen geordnet zu schließen. Für die Anbindung an GitHub Actions empfiehlt sich ein dedizierter Linux-Systembenutzer deploy, der über /etc/sudoers.d/deploy ausschließlich zur Ausführung von /opt/stacks/prod-web/rollout.sh berechtigt ist.
#!/usr/bin/env bash
set -euo pipefail
STACK_DIR="/opt/stacks/prod-web"
NEW_IMAGE="${1:-}"
cd "$STACK_DIR"
if [[ -n "$NEW_IMAGE" ]]; then
sed -i "s|^APP_IMAGE=.*|APP_IMAGE=${NEW_IMAGE}|" .env
fi
echo "==> [1/4] Neues Anwendungs-Image wird heruntergeladen..."
docker compose pull app
echo "==> [2/4] Skaliere app auf 2 Instanzen und warte auf /healthz..."
docker compose up -d --no-deps --scale app=2 --no-recreate app
# Bis zu 30 Sekunden warten, bis alle app-Container den Status healthy melden
for i in {1..15}; do
UNHEALTHY=$(docker compose ps app --format json | grep -v '"Health":"healthy"' || true)
if [[ -z "$UNHEALTHY" ]]; then
echo "==> Beide app-Instanzen sind healthy!"
break
fi
sleep 2
done
echo "==> [3/4] Beende alten Container kontrolliert und skaliere zurück auf 1 Instanz..."
OLD_CONTAINER=$(docker compose ps -q app | head -n 1)
docker stop -t 15 "$OLD_CONTAINER"
docker rm "$OLD_CONTAINER"
docker compose up -d --no-deps --scale app=1 --no-recreate app
echo "==> [4/4] Bereinige nicht mehr referenzierte Images..."
docker image prune -f
echo "==> Zero-Downtime-Rollout erfolgreich abgeschlossen!" Befehlserklärung: Führt ein unterbrechungsfreies Blue/Green-Rolling-Update auf einem einzelnen Netcup-Host mittels Docker-Compose-DNS-Round-Robin und aktiven Caddy-Healthchecks durch.
name: Build & Deploy to Netcup Production
on:
push:
branches: ["main"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push Immutable SHA Image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Trigger Zero-Downtime Rollout on Netcup Server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.NETCUP_SSH_HOST }}
port: ${{ secrets.NETCUP_SSH_PORT }}
username: ${{ secrets.NETCUP_SSH_USER }}
key: ${{ secrets.NETCUP_SSH_PRIVATE_KEY }}
script: |
sudo /opt/stacks/prod-web/rollout.sh "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}" Befehlserklärung: Baut ein unveränderliches Container-Image mit dem Git-Commit-SHA als Tag, überträgt es in die GHCR und löst /opt/stacks/prod-web/rollout.sh per SSH auf dem Netcup-Server aus.
SICHERHEITSHINWEIS: Gewähren Sie Ihrem CI/CD-SSH-Benutzer (deploy) über /etc/sudoers.d/deploy passwortloses sudo ausschließlich für /opt/stacks/prod-web/rollout.sh (deploy ALL=(root) NOPASSWD: /opt/stacks/prod-web/rollout.sh) anstatt voller Root-Rechte.
Häufige Fehlerbehebung
DIAGNOSE-GUIDE [SYMPTOM] Caddy scheitert bei der ACME-TLS-Zertifikatsausstellung mit Timeout during connect (likely firewall problem)
Ursache: Entweder wird Port 80/tcp bzw. 443/tcp in der Netcup SCP Firewall oder in UFW blockiert, ODER Ihre Domain besitzt einen AAAA-IPv6-Eintrag, der auf eine nicht erreichbare IPv6-Adresse Ihres Servers zeigt.
Lösung: Testen Sie die externe Dual-Stack-Erreichbarkeit von einem externen System aus mit curl -4 -I http://app.example.com und curl -6 -I http://app.example.com. Korrigieren Sie das IPv6-Routing oder entfernen Sie den fehlerhaften AAAA-Eintrag.
[SYMPTOM] Browser verbinden sich trotz Alt-Svc: h3=":443"-Header nur über HTTP/2 statt HTTP/3 (QUIC)
Ursache: Port 443/udp ist in der compose.yaml nicht veröffentlicht (443:443/udp) oder wird durch ufw bzw. die Netcup SCP Firewall blockiert (welche standardmäßig nur TCP 443 durchlässt, bis UDP 443 explizit freigegeben wird).
Lösung: Prüfen Sie mit sudo ss -ulnp | grep 443, ob Docker/Caddy auf UDP 443 lauscht, und stellen Sie sicher, dass sudo ufw allow 443/udp sowie die SCP Firewall eingehendes UDP 443 erlauben.
[SYMPTOM] Anwendungs-Logs zeigen als Client-IP immer 172.28.0.2 anstelle der echten Besucher-IP
Ursache: Anfragen erreichen den app-Container über den Caddy-Reverse-Proxy innerhalb des internen Docker-Bridge-Netzwerks, und das Web-Framework vertraut den Headern X-Forwarded-For / X-Real-IP aus privaten Subnetzen noch nicht.
Lösung: Aktivieren Sie Proxy-Header-Trust in Ihrem Framework (z. B. app.set("trust proxy", true) in Express/Fastify oder --proxy-headers in Uvicorn/Gunicorn) passend zu Caddys trusted_proxies static private_ranges.
[SYMPTOM] Caddy protokolliert failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 7168 kiB)
Ursache: Die Sysctl-Werte net.core.rmem_max und net.core.wmem_max des Linux-Host-Kernels stehen noch auf dem Ubuntu-Standardwert (212992 Bytes).
Lösung: Wenden Sie die Einstellungen aus /etc/sysctl.d/99-netcup-web-tuning.conf aus Schritt 1 an (sudo sysctl --system) und starten Sie anschließend den Caddy-Container neu (sudo docker compose restart caddy).
Häufig gestellte Fragen (FAQ)
GEPRÜFTE ANTWORTENKann ich mit diesem Setup mehrere unabhängige Websites und Domains auf einem einzigen Netcup VPS 1000 G12.5 betreiben?
Ja. Betreiben Sie einen einzelnen gemeinsamen Edge-Caddy-Container auf den Ports 80 und 443, der an ein externes Docker-Netzwerk (docker network create caddy_edge) angeschlossen ist, und hängen Sie den app-Dienst jeder Website in dieses caddy_edge-Netzwerk. Fügen Sie einen neuen Domain-Block in der Caddyfile hinzu (site2.example.com { reverse_proxy site2_app:3000 }) und führen Sie docker exec prod_web_caddy caddy reload --config /etc/caddy/Caddyfile aus, um das neue Zertifikat ohne Downtime auszustellen.
Sollte ich Cloudflare Proxy (Orange Cloud) vor Caddy auf einem Netcup-Server aktivieren?
Wenn Ihre Zielgruppe primär in Europa (oder nahe Ihres gewählten Netcup-Standorts in Nürnberg, Wien, Amsterdam, Manassas oder Singapur) sitzt, spart die direkte Auslieferung über Caddy HTTP/3 vom Netcup-Server einen zusätzlichen TLS-Terminierungs-Hop und senkt die TTFB oft um 15–35 ms. Wenn Sie Cloudflare Orange Cloud nutzen möchten, konfigurieren Sie Caddy mit einem Cloudflare Origin CA-Zertifikat oder DNS-01-Challenge und stellen Sie den SSL/TLS-Modus bei Cloudflare zwingend auf Full (Strict).
Wie lade ich Änderungen an der Caddyfile neu, ohne aktive WebSocket- oder HTTP/3-Verbindungen zu trennen?
Führen Sie bei Konfigurationsänderungen niemals docker restart prod_web_caddy aus. Validieren und laden Sie die Konfiguration stattdessen im laufenden Container neu: sudo docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile. Caddy startet dabei neue Worker und beendet alte Verbindungen erst nach deren Abschluss.
Warum binden wir postgresql.conf schreibgeschützt (:ro) ein, anstatt /var/lib/postgresql/data/postgresql.conf direkt zu bearbeiten?
Durch die Ablage unter /opt/stacks/prod-web/postgres/postgresql.conf und das Einbinden per :ro bleiben Ihre Datenbank-Tuning-Parameter zusammen mit der compose.yaml sauber versionskontrollierbar (Git) und strikt vom binären pg_data-Volume getrennt.
QUELLEN // GEPRÜFTE TECHNISCHE DOKUMENTATION
- [1] Caddy v2 Offizielle Reverse-Proxy- & Auto-HTTPS-Dokumentation (https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)
- [2] quic-go Leitfaden zur Optimierung der UDP-Empfangspuffer (https://github.com/quic-go/quic-go/wiki/UDP-Buffer-Sizes)
- [3] PostgreSQL 16 Dokumentation zur Ressourcen- & Speicherkonfiguration (https://www.postgresql.org/docs/16/runtime-config-resource.html)