Skip to main content

COMMUNITY KNOWLEDGE FORUM Independent Hosting Community • Not affiliated with netcup GmbH.

NETCUP GUIDE
FORUM · LIVE VOUCHER POOL
Tutorials LEVEL: Intermediate TIME: 40 minutes // 19 MIN READ • VERIFIED:

How to Deploy a Zero-Downtime Production Website with Docker, Caddy v2 & GitHub Actions on Netcup

Deploying a production web application on a Netcup VPS or Root Server requires more than exposing port 80 on a single container. A resilient architecture terminates TLS 1.3 and HTTP/3 (QUIC) at an edge reverse proxy, isolates PostgreSQL and Redis on an air-gapped internal network, tunes Linux kernel UDP buffers for high-speed QUIC delivery, and performs zero-downtime rolling updates when new commits land on main. This end-to-end engineering tutorial builds that complete stack using Docker Compose v2, Caddy v2, PostgreSQL 16, Redis 7, and an automated GitHub Actions SSH deployment pipeline.

THREAD OP // AUTHOR Marcus Vance Principal Systems Architect
MOD VERIFIED // CO-MAINTAINER Elena Rostova Senior Network & Storage Verification Editor
THREAD STATUS // UPDATED Community Standards →
MODERATOR NOTE // TEST ENVIRONMENT & TRANSPARENCY Verified using Caddy 2.8+, PostgreSQL 16.4, Redis 7.4, and Docker Compose v2 on a dual-stack Netcup RS 1000 G12.5 instance in Nuremberg (AS197540).

PREREQUISITES // ENVIRONMENT REQUIREMENTS

CHECKLIST
  • ✓ A Netcup VPS (such as an 8 GB VPS 1000 G12.5) or Root Server (such as an 8 GB RS 1000 G12.5) running Ubuntu 24.04 LTS or Debian 12 with Docker Compose v2 installed
  • ✓ A registered domain name with DNS A (IPv4) and AAAA (IPv6) records pointing to your Netcup server
  • ✓ Ports 80/tcp, 443/tcp, and 443/udp permitted in both UFW and the Netcup SCP Firewall

Key Takeaways

EXECUTIVE BRIEFING
  • 01 Publish only ports 80/tcp, 443/tcp, and 443/udp on the Caddy edge container; keep application, PostgreSQL, and Redis containers strictly off the host’s public network interfaces.
  • 02 Increase Linux kernel UDP socket receive/send buffers (net.core.rmem_max = 7500000) via sysctl so Caddy’s quic-go stack can serve HTTP/3 traffic at full 1–2.5 Gbit/s Netcup line speed without buffer drops.
  • 03 Configure active upstream health checks (health_uri /healthz) and retry windows (lb_try_duration 10s) inside Caddyfile to achieve zero dropped requests during container image rollouts.
  • 04 Tune PostgreSQL 16 parameters (shared_buffers, effective_cache_size, random_page_cost=1.1) specifically for Netcup’s local RAID10 NVMe storage subsystem.
  • 05 Automate deployments via GitHub Actions using a dedicated, least-privilege deploy user and a short-lived rollout script that rolls back automatically if health probes fail.
📑 Quick Jump Table of Contents (5 Sections) ▼
§ 01

Step 1: Verify Dual-Stack DNS Records & Tune Kernel Buffers for HTTP/3 QUIC

Before starting an ACME TLS reverse proxy like Caddy v2 or Traefik v3, verify that both your A (IPv4) and AAAA (IPv6) DNS records resolve accurately to your Netcup server. Let’s Encrypt and ZeroSSL validation authorities strictly prefer IPv6 (AAAA) when an AAAA record exists in DNS; if your AAAA record contains a typo or your Netcup instance’s static fe80::1 IPv6 default gateway route is misconfigured, ACME HTTP-01 and TLS-ALPN-01 challenges will time out even when IPv4 responds normally.

Once DNS resolves cleanly, Caddy v2 handles the entire ACME certificate lifecycle automatically: it generates ECDSA P-256 private keys, completes HTTP-01 or TLS-ALPN-01 challenges, staples OCSP responses, and renews certificates 30 days before expiration. All account keys and issued certificates are persisted inside the /data volume (caddy_data), which must never be wiped during routine application deployments.

Additionally, HTTP/3 runs over QUIC on UDP port 443. Standard Ubuntu and Debian kernels ship with a conservative default UDP receive buffer (rmem_max = 212992 bytes, ~208 KiB). Under concurrent HTTP/3 page loads on a 1 Gbit/s or 2.5 Gbit/s Netcup link, Caddy’s underlying quic-go engine logs failed to sufficiently increase receive buffer size warnings and drops UDP packets unless you raise the kernel socket buffers to ~7.5 MB and enable TCP BBR congestion control.

Verify Dual-Stack DNS & Tune Kernel Sysctl for HTTP/3 QUIC + TCP BBR
bash
# 1. Verify A and AAAA records against an external resolver before starting Caddy
dig +short A app.example.com @1.1.1.1
dig +short AAAA app.example.com @1.1.1.1

# 2. Tune UDP socket buffers for HTTP/3 (QUIC) and enable TCP BBR congestion control
sudo tee /etc/sysctl.d/99-netcup-web-tuning.conf > /dev/null <<'EOF'
# Increase UDP receive/send buffers to ~7.5 MB for high-throughput HTTP/3 QUIC
net.core.rmem_max = 7500000
net.core.wmem_max = 7500000
net.core.rmem_default = 1048576
net.core.wmem_default = 1048576

# Enable BBR congestion control & raise listen backlog for burst web traffic
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. Allow HTTP, HTTPS (TCP), and HTTP/3 QUIC (UDP 443) in UFW
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

Command Explanation: Verifies public IPv4/IPv6 DNS propagation, applies kernel UDP buffer and TCP BBR tuning for high-speed HTTP/3 delivery, and opens TCP 80/443 plus UDP 443 in UFW.

EXPECTED OUTPUT
* 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 on Single-Node Netcup Servers

While Traefik v3 excels at dynamic Docker label discovery across multi-tenant clusters, Caddy v2 is often preferred for single-node Netcup VPS and Root Server deployments due to its ~35 MB RAM footprint, native Caddyfile syntax, on-the-fly Zstandard (zstd) compression, and zero-config OCSP stapling without mounting the privileged /var/run/docker.sock socket.

§ 02

Step 2: Create the Multi-Tier Production compose.yaml (Caddy + App + Postgres 16 + Redis 7)

We will build the production web stack inside /opt/stacks/prod-web. The architecture separates container traffic across two custom Linux bridges: an edge edge_net (connecting Caddy and the application containers) and an air-gapped db_net (internal: true, connecting the application containers to PostgreSQL 16 and Redis 7). Docker’s embedded DNS server (127.0.0.11) automatically resolves service names within each shared bridge, so caddy can resolve app:3000 on edge_net while having zero network visibility into postgres:5432 or redis:6379 on db_net.

Notice that the app service deliberately omits both a static container_name and host ports: bindings, using expose: ["3000"] instead. If you hardcode container_name: prod_web_app or bind a fixed host port, Docker Compose cannot run two instances of app simultaneously—which is required for our blue/green zero-downtime rollout (docker compose up -d --scale app=2) in Step 5.

For the postgres service, we also configure shm_size: 256mb. By default, Docker mounts /dev/shm inside containers with a 64 MB ceiling; when PostgreSQL 16 spawns parallel background workers for complex joins or index builds on a 4-core or 8-core Netcup node, exhausting that 64 MB /dev/shm limit triggers immediate query failures (ERROR: could not resize shared memory segment: No space left on device).

Initialize /opt/stacks/prod-web & Create .env Configuration
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

Command Explanation: Creates the directory layout, generates a 256-bit database password secret file, and populates .env with domain and image release parameters.

/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

Command Explanation: Complete production Compose file isolating Postgres 16 and Redis 7 on db_net while allowing app to scale horizontally behind caddy on edge_net.

SECURITY NOTE: Never remove the caddy_data named volume during routine deployments; it stores your issued ACME certificates and account keys. Deleting and recreating it repeatedly triggers Let’s Encrypt’s 5-certificates-per-168-hours duplicate certificate rate limit.

§ 03

Step 3: Configure Caddyfile with Active Health Checks, HTTP/3 & Security Headers

To guarantee zero dropped HTTP requests during application deployments, configure Caddy’s reverse_proxy block with active background health checks (health_uri /healthz, health_interval 5s) alongside a request retry buffer (lb_try_duration 10s, lb_try_interval 250ms). When Docker’s internal DNS returns multiple A records for app during a rollout, Caddy probes each container IP independently and holds any in-flight request for up to 10 seconds if an upstream socket closes mid-drain, seamlessly retrying against the healthy replica.

Our production Caddyfile also enforces canonical www to apex redirects, negotiates Zstandard (zstd) compression ahead of legacy gzip, injects hardened OWASP security headers (Strict-Transport-Security, X-Content-Type-Options, Permissions-Policy), strips the fingerprinting Server response header, and writes structured JSON access logs with automatic 50 MiB file rotation.

/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
    }
}

Command Explanation: Configures Caddy v2 with HTTP/1.1, HTTP/2, and HTTP/3 protocols, canonical www redirect, Zstandard/Gzip compression, hardened OWASP response headers, active /healthz load-balancer probes, and rotated JSON access logs.

§ 04

Step 4: Tune PostgreSQL 16 (postgresql.conf) for Netcup RAID10 NVMe Storage

Out of the box, PostgreSQL 16 ships with conservative defaults designed for legacy spinning magnetic disks and tiny memory footprints (random_page_cost = 4.0, shared_buffers = 128MB, effective_io_concurrency = 1). On an 8 GB Netcup VPS 1000 G12.5 or RS 1000 G12.5 backed by enterprise RAID10 NVMe storage, leaving random_page_cost at 4.0 causes the query planner to overestimate random index lookup costs by 4x—frequently choosing slow sequential full-table scans over B-tree index scans as tables grow past a few hundred thousand rows.

Create /opt/stacks/prod-web/postgres/postgresql.conf and mount it read-only (:ro) into the container. Lowering random_page_cost to 1.1, raising effective_io_concurrency to 200, allocating 25% of the container’s 3 GB memory ceiling (shared_buffers = 768MB), enabling wal_compression = zstd, and logging queries slower than 200 ms (log_min_duration_statement = 200) aligns PostgreSQL with Netcup’s NVMe I/O characteristics.

PostgreSQL 16 Tuning Parameters by Netcup Server Tier
ParameterDefault Postgres ValueVPS 1000 G12.5 / RS 1000 G12.5 (8 GB RAM)RS 2000 G12.5 (16 GB RAM)
shared_buffers128MB768MB (25% of 3 GB container)1536MB (25% of 6 GB container)
effective_cache_size4GB2048MB4096MB
work_mem4MB16MB32MB
maintenance_work_mem64MB192MB384MB
random_page_cost4.0 (Spinning HDD)1.1 (Enterprise NVMe)1.1 (Enterprise NVMe)
effective_io_concurrency1200200
/opt/stacks/prod-web/postgres/postgresql.conf
ini
# Connection & Authentication
listen_addresses = '*'
max_connections = 100
password_encryption = scram-sha-256

# Memory Allocation (Tuned for 3 GB Postgres Container on 8 GB Netcup Node)
shared_buffers = 768MB
effective_cache_size = 2048MB
work_mem = 16MB
maintenance_work_mem = 192MB
huge_pages = try

# Netcup Enterprise NVMe I/O & Planner Cost Tuning
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 Logging
logging_collector = off
log_min_duration_statement = 200
log_checkpoints = on
log_lock_waits = on

Command Explanation: Optimizes PostgreSQL 16 for Netcup NVMe storage with SCRAM-SHA-256 authentication, Zstd WAL compression, random_page_cost = 1.1, and automatic slow-query logging (>200ms).

§ 05

Step 5: Zero-Downtime Rollout Script & GitHub Actions SSH Deployment Pipeline

If you deploy updates by running docker compose down && docker compose up -d, your visitors encounter 502 Bad Gateway or Connection Refused errors for several seconds while the old container terminates and the new runtime initializes. Even docker compose up -d app stops the old container before starting the new one unless horizontal scaling is used.

To achieve true zero-downtime rollouts on a single Netcup host, create /opt/stacks/prod-web/rollout.sh. The script updates APP_IMAGE in .env, pulls the new image layer in the background, scales app to 2 concurrent instances (--scale app=2 --no-recreate), and polls docker compose ps until the new container’s /healthz probe reports healthy. Only after both containers are actively serving traffic behind Caddy does the script send SIGTERM (docker stop -t 15) to the older container ID, allowing in-flight HTTP requests to drain cleanly before scaling back to 1 replica.

Finally, wire this script into .github/workflows/deploy-netcup.yml. By building an immutable image tagged with the exact Git commit SHA (${{ github.sha }}) on GitHub’s runners and restricting the SSH deploy user via /etc/sudoers.d/deploy strictly to /opt/stacks/prod-web/rollout.sh, your Netcup production server never needs source code, build compilers, or broad root SSH keys exposed to CI.

/opt/stacks/prod-web/rollout.sh (Zero-Downtime Rolling Update Script)
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] Pulling new application image..."
docker compose pull app

echo "==> [2/4] Scaling app to 2 instances and waiting for /healthz..."
docker compose up -d --no-deps --scale app=2 --no-recreate app

# Wait up to 30 seconds for all app containers to report healthy
for i in {1..15}; do
  UNHEALTHY=$(docker compose ps app --format json | grep -v '"Health":"healthy"' || true)
  if [[ -z "$UNHEALTHY" ]]; then
    echo "==> Both app instances are healthy!"
    break
  fi
  sleep 2
done

echo "==> [3/4] Draining old container and scaling back to 1 instance..."
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] Pruning dangling images..."
docker image prune -f
echo "==> Zero-downtime rollout complete!"

Command Explanation: Executes a zero-downtime blue/green rolling update on a single Netcup host using Docker Compose internal DNS round-robin and Caddy health checks.

.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 }}"

Command Explanation: Builds an immutable container image tagged with the Git commit SHA, pushes it to GHCR, and triggers /opt/stacks/prod-web/rollout.sh over SSH.

SECURITY NOTE: Grant your CI/CD SSH user (deploy) passwordless sudo strictly for /opt/stacks/prod-web/rollout.sh via /etc/sudoers.d/deploy (deploy ALL=(root) NOPASSWD: /opt/stacks/prod-web/rollout.sh) rather than full root privileges.

Common Troubleshooting Scenarios

DIAGNOSTIC RUNBOOK

[SYMPTOM] Caddy Fails ACME TLS Certificate Issuance with Timeout during connect (likely firewall problem)

Root Cause: Either port 80/tcp or 443/tcp is blocked in the Netcup SCP Firewall or UFW, OR your domain has an AAAA IPv6 record pointing to an unreachable IPv6 address on your server.

Resolution: Test external dual-stack reachability with curl -4 -I http://app.example.com and curl -6 -I http://app.example.com from an external machine. Fix IPv6 routing or remove the stale AAAA record.

[SYMPTOM] Browsers Connect over HTTP/2 Instead of HTTP/3 (QUIC) Despite Alt-Svc: h3=":443" Header

Root Cause: Port 443/udp is not published in compose.yaml (443:443/udp) or is blocked by ufw / the Netcup SCP Firewall (which only permits TCP 443 unless UDP 443 is explicitly added).

Resolution: Verify sudo ss -ulnp | grep 443 shows Docker/Caddy listening on UDP 443, and confirm sudo ufw allow 443/udp and the SCP Firewall permit inbound UDP 443.

[SYMPTOM] Application Logs Show Client IP as 172.28.0.2 Instead of the Real Visitor IP

Root Cause: Requests arrive at the app container via the Caddy reverse proxy over the internal Docker bridge network, and the application framework is not configured to trust X-Forwarded-For / X-Real-IP from private subnets.

Resolution: Enable proxy header trust in your framework (e.g., app.set("trust proxy", true) in Express/Fastify or --proxy-headers in Uvicorn/Gunicorn) alongside Caddy’s trusted_proxies static private_ranges.

[SYMPTOM] Caddy Logs failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 7168 kiB)

Root Cause: The host Linux kernel net.core.rmem_max and net.core.wmem_max sysctl values are still set to the Ubuntu default (212992 bytes).

Resolution: Apply the /etc/sysctl.d/99-netcup-web-tuning.conf settings from Step 1 (sudo sysctl --system) and restart the Caddy container (sudo docker compose restart caddy).

Frequently Asked Questions

VERIFIED Q&A

Can I host multiple independent websites and domains on a single Netcup VPS 1000 using this setup?

Yes. Run a single shared edge Caddy container bound to ports 80 and 443 attached to an external Docker network (docker network create caddy_edge), and attach each website’s compose.yaml application service to caddy_edge. Add a new domain block in Caddyfile (site2.example.com { reverse_proxy site2_app:3000 }) and run docker exec prod_web_caddy caddy reload --config /etc/caddy/Caddyfile to provision the new certificate with zero downtime.

Should I use Cloudflare Proxy (Orange Cloud) in front of Caddy on a Netcup server?

If your primary audience is in Europe (or near your chosen Netcup data center in Nuremberg, Vienna, Amsterdam, Manassas, or Singapore), serving traffic directly from Netcup via Caddy HTTP/3 eliminates an extra TLS termination hop and lowers TTFB by 15–35 ms. If you enable Cloudflare Orange Cloud, configure Caddy with a Cloudflare Origin CA certificate or DNS-01 challenge and set SSL/TLS mode to Full (Strict).

How do I reload Caddyfile changes without dropping active WebSocket or HTTP/3 connections?

Never run docker restart prod_web_caddy for configuration changes. Instead, validate and hot-reload inside the container: sudo docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile. Caddy gracefully spawns new workers while draining existing connections.

Why do we bind postgresql.conf read-only instead of editing /var/lib/postgresql/data/postgresql.conf?

Storing postgresql.conf inside /opt/stacks/prod-web/postgres/postgresql.conf and mounting it with :ro keeps your database tuning parameters version-controlled alongside compose.yaml and separated from the binary pg_data volume.

REFERENCES // VERIFIED TECHNICAL SOURCES

FURTHER READING // ARCHITECTURE AUDITS & GUIDES

Active Coupons →