Zum Hauptinhalt springen

UNABHÄNGIGES WISSENSFORUM Unabhängige Hosting-Community • Keine offizielle Verbindung zur netcup GmbH.

NETCUP GUIDE
FORUM · GUTSCHEINPOOL
Tutorials Stufe: Mittelstufe Dauer: 40 Minuten // 19 Min. Lesezeit • Geprüft:

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.

THREAD-AUTOR // OP Marcus Vance Lead Cloud Infrastructure Architect (EU KVM & Netzwerk-Engineering)
MOD-PRÜFUNG // CO-MAINTAINER Elena Rostova Senior Linux Systems & Security Auditor
BEITRAGSSTATUS // AKTUALISIERT Community-Standards →
MODERATOR-HINWEIS // TESTUMGEBUNG & TRANSPARENZ Verifiziert mit Caddy 2.8+, PostgreSQL 16.4, Redis 7.4 und Docker Compose v2 auf einer Dual-Stack Netcup RS 1000 G12.5 Instanz im Rechenzentrum Nürnberg (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) und AAAA-Einträgen (IPv6), die auf Ihren Netcup-Server zeigen
  • ✓ Freigeschaltete Ports 80/tcp, 443/tcp und 443/udp sowohl 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/tcp und 443/udp am 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) per sysctl, damit der quic-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 der Caddyfile, 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

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.

Dual-Stack-DNS prüfen & Kernel-Sysctl für HTTP/3 QUIC + TCP BBR optimieren
bash
# 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.

ERWARTETE AUSGABE
* Applying /etc/sysctl.d/99-netcup-web-tuning.conf ...
net.core.rmem_max = 7500000
net.ipv4.tcp_congestion_control = bbr
Caddy v2 vs. Traefik v3 auf Single-Node-Netcup-Servern

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.

§ 02

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.

/opt/stacks/prod-web initialisieren & .env-Konfiguration anlegen
bash
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.

/opt/stacks/prod-web/compose.yaml
yaml
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).

§ 03

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.

/opt/stacks/prod-web/caddy/Caddyfile
text
{
    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.

§ 04

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:

PostgreSQL 16 Tuning-Parameter nach Netcup Server-Tarif
ParameterStandardwert PostgresVPS 1000 G12.5 / RS 1000 G12.5 (8 GB RAM)RS 2000 G12.5 (16 GB RAM)
shared_buffers128MB768MB (25 % des 3-GB-Containers)1536MB (25 % des 6-GB-Containers)
effective_cache_size4GB2048MB4096MB
work_mem4MB16MB32MB
maintenance_work_mem64MB192MB384MB
random_page_cost4.0 (Rotierende HDD)1.1 (Enterprise NVMe)1.1 (Enterprise NVMe)
effective_io_concurrency1200200
/opt/stacks/prod-web/postgres/postgresql.conf
ini
# 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).

§ 05

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.

/opt/stacks/prod-web/rollout.sh (Skript für unterbrechungsfreie Rolling Updates)
bash
#!/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.

.github/workflows/deploy-netcup.yml (GitHub Actions CI/CD-Workflow)
yaml
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 ANTWORTEN

Kann 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

WEITERFÜHREND // EMPFOHLENE ARCHITEKTUR-AUDITS

Gutscheine →