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.
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) andAAAA(IPv6) records pointing to your Netcup server - ✓ Ports
80/tcp,443/tcp, and443/udppermitted in both UFW and the Netcup SCP Firewall
Key Takeaways
EXECUTIVE BRIEFING-
01 Publish only ports
80/tcp,443/tcp, and443/udpon 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) viasysctlso Caddy’squic-gostack 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) insideCaddyfileto 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
deployuser 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
- 02 Step 2: Create the Multi-Tier Production compose.yaml (Caddy + App + Postgres 16 + Redis 7)
- 03 Step 3: Configure Caddyfile with Active Health Checks, HTTP/3 & Security Headers
- 04 Step 4: Tune PostgreSQL 16 (postgresql.conf) for Netcup RAID10 NVMe Storage
- 05 Step 5: Zero-Downtime Rollout Script & GitHub Actions SSH Deployment Pipeline
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.
# 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.
* Applying /etc/sysctl.d/99-netcup-web-tuning.conf ...
net.core.rmem_max = 7500000
net.ipv4.tcp_congestion_control = bbr 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.
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).
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.
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.
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.
{
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.
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.
| Parameter | Default Postgres Value | VPS 1000 G12.5 / RS 1000 G12.5 (8 GB RAM) | RS 2000 G12.5 (16 GB RAM) |
|---|---|---|---|
shared_buffers | 128MB | 768MB (25% of 3 GB container) | 1536MB (25% of 6 GB container) |
effective_cache_size | 4GB | 2048MB | 4096MB |
work_mem | 4MB | 16MB | 32MB |
maintenance_work_mem | 64MB | 192MB | 384MB |
random_page_cost | 4.0 (Spinning HDD) | 1.1 (Enterprise NVMe) | 1.1 (Enterprise NVMe) |
effective_io_concurrency | 1 | 200 | 200 |
# 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).
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.
#!/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.
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&ACan 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
- [1] Caddy v2 Official Reverse Proxy & Automatic HTTPS Documentation (https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)
- [2] quic-go UDP Receive Buffer Size Optimization Guide (https://github.com/quic-go/quic-go/wiki/UDP-Buffer-Sizes)
- [3] PostgreSQL 16 Runtime Configuration & Resource Consumption Docs (https://www.postgresql.org/docs/16/runtime-config-resource.html)