Files
michaelschiemer/docker-compose.staging.yml
Michael Schiemer 85e2360a90
All checks were successful
Test Runner / test-basic (push) Successful in 8s
Test Runner / test-php (push) Successful in 7s
Deploy Application / deploy (push) Successful in 1m35s
fix(deploy): improve deployment robustness and reliability
- Add docker volume prune to deploy.sh to prevent stale code issues
- Add automatic migrations and cache warmup to staging entrypoint
- Fix nginx race condition by waiting for PHP-FPM before starting
- Improve PHP healthcheck to use php-fpm-healthcheck
- Add curl to production nginx Dockerfile for healthchecks
- Add ensureSeedsTable() to SeedRepository for automatic table creation
- Update SeedCommand to ensure seeds table exists before operations

This prevents 502 Bad Gateway errors during deployment and ensures
fresh code is deployed without volume cache issues.
2025-11-25 17:44:44 +01:00

523 lines
20 KiB
YAML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Staging Environment Override
# Usage: docker-compose -f docker-compose.base.yml -f docker-compose.staging.yml up
#
# This file overrides base configuration with staging-specific settings:
# - Container names with "staging-" prefix
# - Traefik integration for staging.michaelschiemer.de
# - Git clone functionality for staging branch
# - Staging-specific networks (traefik-public, staging-internal)
# - Staging-specific volumes
services:
# PHP-FPM Application Runtime
php:
image: localhost:5000/framework:latest
container_name: php
restart: unless-stopped
networks:
- app-backend
- app-internal
environment:
- TZ=Europe/Berlin
- APP_ENV=staging
- APP_DEBUG=false
- APP_URL=https://staging.michaelschiemer.de
- APP_KEY=${APP_KEY:-}
# Git Repository - clones staging branch
- GIT_REPOSITORY_URL=${GIT_REPOSITORY_URL:-}
- GIT_BRANCH=staging
- GIT_TOKEN=${GIT_TOKEN:-}
- GIT_USERNAME=${GIT_USERNAME:-}
- GIT_PASSWORD=${GIT_PASSWORD:-}
# Database - using separate staging database
- DB_HOST=postgres
- DB_PORT=5432
- DB_DATABASE=${DB_DATABASE:-michaelschiemer_staging}
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
# Redis
- REDIS_HOST=redis
- REDIS_PORT=6379
# Cache
- CACHE_DRIVER=redis
- CACHE_PREFIX=${CACHE_PREFIX:-staging}
# Session
- SESSION_DRIVER=redis
- SESSION_LIFETIME=${SESSION_LIFETIME:-120}
# Queue
- QUEUE_DRIVER=redis
- QUEUE_CONNECTION=default
# Use Docker Secrets via *_FILE pattern (Framework supports this automatically)
# Note: These paths will be set by the entrypoint script after copying secrets
# to /var/www/html/storage/secrets/ for www-data access
# The entrypoint script will copy secrets and set these paths
- DB_PASSWORD_FILE=/var/www/html/storage/secrets/db_user_password
- REDIS_PASSWORD_FILE=/var/www/html/storage/secrets/redis_password
- APP_KEY_FILE=/var/www/html/storage/secrets/app_key
volumes:
- app-code:/var/www/html
- app-storage:/var/www/html/storage
- app-logs:/var/www/html/storage/logs
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
secrets:
- db_user_password
- redis_password
- app_key
# Override entrypoint to only start PHP-FPM (not nginx) + fix git ownership
entrypoint: ["/bin/sh", "-c"]
command:
- |
# Copy Docker Secrets to readable location for www-data
# Docker Secrets are only readable by root, but PHP (www-data) needs to read them.
# We copy them here as root to a location where www-data can read them.
# Note: Use $$ to escape shell variables in docker-compose YAML
echo "🔐 Setting up Docker Secrets for PHP access..."
SECRETS_DIR="/var/www/html/storage/secrets"
# Ensure we're in the right directory
cd /var/www/html || exit 1
# Create secrets directory if it doesn't exist
mkdir -p "$$SECRETS_DIR"
chmod 750 "$$SECRETS_DIR"
chown www-data:www-data "$$SECRETS_DIR"
if [ -f /run/secrets/redis_password ]; then
cp /run/secrets/redis_password "$$SECRETS_DIR/redis_password" 2>/dev/null || true
chmod 640 "$$SECRETS_DIR/redis_password"
chown www-data:www-data "$$SECRETS_DIR/redis_password"
export REDIS_PASSWORD_FILE="$$SECRETS_DIR/redis_password"
echo "✅ Copied redis_password to $$SECRETS_DIR/redis_password"
else
echo "⚠️ Warning: /run/secrets/redis_password not found"
fi
if [ -f /run/secrets/db_user_password ]; then
cp /run/secrets/db_user_password "$$SECRETS_DIR/db_user_password" 2>/dev/null || true
chmod 640 "$$SECRETS_DIR/db_user_password"
chown www-data:www-data "$$SECRETS_DIR/db_user_password"
export DB_PASSWORD_FILE="$$SECRETS_DIR/db_user_password"
echo "✅ Copied db_user_password to $$SECRETS_DIR/db_user_password"
else
echo "⚠️ Warning: /run/secrets/db_user_password not found"
fi
if [ -f /run/secrets/app_key ]; then
cp /run/secrets/app_key "$$SECRETS_DIR/app_key" 2>/dev/null || true
chmod 640 "$$SECRETS_DIR/app_key"
chown www-data:www-data "$$SECRETS_DIR/app_key"
export APP_KEY_FILE="$$SECRETS_DIR/app_key"
echo "✅ Copied app_key to $$SECRETS_DIR/app_key"
else
echo "⚠️ Warning: /run/secrets/app_key not found"
fi
# Fix Git ownership issue
# Ensure Git treats the mounted repository as safe regardless of owner
git config --global --add safe.directory /var/www/html 2>/dev/null || true
git config --system --add safe.directory /var/www/html 2>/dev/null || true
# Git Clone/Pull functionality
if [ -n "$GIT_REPOSITORY_URL" ]; then
echo ""
echo "📥 Cloning/Pulling code from Git repository..."
GIT_BRANCH="${GIT_BRANCH:-main}"
GIT_TARGET_DIR="/var/www/html"
# Setup Git credentials
if [ -n "$GIT_TOKEN" ]; then
GIT_URL_WITH_AUTH=$(echo "$GIT_REPOSITORY_URL" | sed "s|https://|https://${GIT_TOKEN}@|")
elif [ -n "$GIT_USERNAME" ] && [ -n "$GIT_PASSWORD" ]; then
GIT_URL_WITH_AUTH=$(echo "$GIT_REPOSITORY_URL" | sed "s|https://|https://${GIT_USERNAME}:${GIT_PASSWORD}@|")
else
GIT_URL_WITH_AUTH="$GIT_REPOSITORY_URL"
fi
# Clone or pull
if [ ! -d "$GIT_TARGET_DIR/.git" ]; then
echo "📥 Cloning repository from $GIT_REPOSITORY_URL (branch: $GIT_BRANCH)..."
if [ "$(ls -A $GIT_TARGET_DIR 2>/dev/null)" ]; then
find "$GIT_TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name "storage" -exec rm -rf {} \; 2>/dev/null || true
fi
TEMP_CLONE="${GIT_TARGET_DIR}.tmp"
rm -rf "$TEMP_CLONE" 2>/dev/null || true
if git -c safe.directory=/var/www/html clone --branch "$GIT_BRANCH" --depth 1 "$GIT_URL_WITH_AUTH" "$TEMP_CLONE"; then
find "$GIT_TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name "storage" -exec rm -rf {} \; 2>/dev/null || true
find "$TEMP_CLONE" -mindepth 1 -maxdepth 1 ! -name "." ! -name ".." -exec mv {} "$GIT_TARGET_DIR/" \; 2>/dev/null || true
rm -rf "$TEMP_CLONE" 2>/dev/null || true
echo "✅ Repository cloned successfully"
fi
else
echo "🔄 Pulling latest changes from $GIT_BRANCH..."
cd "$GIT_TARGET_DIR"
git -c safe.directory=/var/www/html fetch origin "$GIT_BRANCH" || echo "⚠️ Git fetch failed"
git -c safe.directory=/var/www/html reset --hard "origin/$GIT_BRANCH" || echo "⚠️ Git reset failed"
git -c safe.directory=/var/www/html clean -fd || true
fi
# Install dependencies
if [ -f "$GIT_TARGET_DIR/composer.json" ]; then
echo "📦 Installing/updating Composer dependencies..."
cd "$GIT_TARGET_DIR"
composer install --no-dev --optimize-autoloader --no-interaction --no-scripts || echo "⚠️ Composer install failed"
composer dump-autoload --optimize --classmap-authoritative || true
fi
echo "✅ Git sync completed"
else
echo ""
echo " GIT_REPOSITORY_URL not set, using code from image"
fi
echo ""
echo "📊 Environment variables:"
env | grep -E "DB_|APP_" | grep -v "PASSWORD|KEY|SECRET" || true
# Run database migrations
if [ -f /var/www/html/console.php ]; then
echo ""
echo "🗄️ Running database migrations..."
cd /var/www/html
php console.php db:migrate --force || echo "⚠️ Migration warning (may be OK if already migrated)"
fi
# Warm up caches
echo ""
echo "🔥 Warming up caches..."
cd /var/www/html
php console.php cache:warm 2>/dev/null || echo " Cache warmup skipped (command may not exist)"
echo ""
echo "🛠️ Adjusting filesystem permissions..."
chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache 2>/dev/null || true
find /var/www/html/storage /var/www/html/bootstrap/cache -type d -exec chmod 775 {} \; 2>/dev/null || true
find /var/www/html/storage /var/www/html/bootstrap/cache -type f -exec chmod 664 {} \; 2>/dev/null || true
# Keep PHP-FPM secure with clear_env = yes (default)
# The *_FILE environment variables are passed explicitly via docker-compose environment section
# PHP's DockerSecretsResolver will read the secrets from the files specified in *_FILE vars
# Start PHP-FPM only (no nginx)
echo ""
echo "🚀 Starting PHP-FPM..."
echo "REDIS_PASSWORD_FILE: ${REDIS_PASSWORD_FILE:-NOT SET}"
exec php-fpm
healthcheck:
# Use HTTP liveness check via php-fpm (not via nginx)
# This checks if the PHP application is actually responding
test: ["CMD-SHELL", "php-fpm-healthcheck || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 60s
depends_on:
redis:
condition: service_started
# Nginx Web Server
nginx:
image: localhost:5000/framework:latest
container_name: nginx
restart: unless-stopped
networks:
- traefik-public
- app-backend
environment:
- TZ=Europe/Berlin
- APP_ENV=staging
- APP_DEBUG=false
# Git Repository - clones staging branch
- GIT_REPOSITORY_URL=${GIT_REPOSITORY_URL:-}
- GIT_BRANCH=staging
- GIT_TOKEN=${GIT_TOKEN:-}
- GIT_USERNAME=${GIT_USERNAME:-}
- GIT_PASSWORD=${GIT_PASSWORD:-}
volumes:
- ./deployment/stacks/staging/nginx/conf.d:/etc/nginx/conf.d:ro
- app-code:/var/www/html:ro
- app-storage:/var/www/html/storage:ro
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
# Wait for code to be available (cloned by php container) then start nginx
entrypoint: ["/bin/sh", "-c"]
command:
- |
# Wait for code to be available in shared volume (php container clones it)
GIT_TARGET_DIR="/var/www/html"
echo "⏳ [staging-nginx] Waiting for code to be available in shared volume..."
for i in 1 2 3 4 5 6 7 8 9 10; do
if [ -d "$$GIT_TARGET_DIR/public" ]; then
echo "✅ [staging-nginx] Code found in shared volume"
break
fi
echo " [staging-nginx] Waiting... ($$i/10)"
sleep 2
done
# If code still not available after wait, try to copy from image as fallback
if [ ! -d "$$GIT_TARGET_DIR/public" ] && [ -d "/var/www/html.orig" ]; then
echo "⚠️ [staging-nginx] Code not found in shared volume, copying from image..."
find /var/www/html.orig -mindepth 1 -maxdepth 1 ! -name "storage" -exec cp -r {} "$$GIT_TARGET_DIR/" \; 2>/dev/null || true
fi
# Fix nginx upstream configuration - sites-enabled/default is a symlink to sites-available/default
# This is critical: nginx config uses production-php:9000 but staging uses php container
for NGINX_CONF in /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default; do
if [ -f "$$NGINX_CONF" ]; then
echo "🔧 [staging-nginx] Fixing PHP-FPM upstream in $$NGINX_CONF..."
# Replace production-php with staging php container name
sed -i 's|server production-php:9000;|server php:9000;|g' "$$NGINX_CONF" || true
# Replace localhost/127.0.0.1 references
sed -i '/upstream php-upstream {/,/}/s|server 127.0.0.1:9000;|server php:9000;|g' "$$NGINX_CONF" || true
sed -i '/upstream php-upstream {/,/}/s|server localhost:9000;|server php:9000;|g' "$$NGINX_CONF" || true
# Replace any auto-generated container names (like 5aad84af7c9e_php)
sed -i 's|server [a-zA-Z0-9_-]*php:9000;|server php:9000;|g' "$$NGINX_CONF" || true
# Replace any direct fastcgi_pass references too
sed -i 's|fastcgi_pass 127.0.0.1:9000;|fastcgi_pass php-upstream;|g' "$$NGINX_CONF" || true
sed -i 's|fastcgi_pass localhost:9000;|fastcgi_pass php-upstream;|g' "$$NGINX_CONF" || true
sed -i 's|fastcgi_pass production-php:9000;|fastcgi_pass php-upstream;|g' "$$NGINX_CONF" || true
echo "✅ [staging-nginx] PHP-FPM upstream fixed in $$NGINX_CONF"
fi
done
# Wait for PHP-FPM to be ready before starting nginx
# This prevents 502 Bad Gateway errors during startup
echo "⏳ [staging-nginx] Waiting for PHP-FPM to be ready..."
MAX_WAIT=30
WAITED=0
while [ $$WAITED -lt $$MAX_WAIT ]; do
# Check if PHP-FPM is accepting connections on port 9000
if nc -z php 9000 2>/dev/null; then
echo "✅ [staging-nginx] PHP-FPM is ready on php:9000"
break
fi
echo " [staging-nginx] PHP-FPM not ready yet... ($$WAITED/$$MAX_WAIT)"
sleep 1
WAITED=$$((WAITED + 1))
done
if [ $$WAITED -ge $$MAX_WAIT ]; then
echo "⚠️ [staging-nginx] PHP-FPM did not become ready within $$MAX_WAIT seconds, starting anyway..."
fi
# Start nginx only (no PHP-FPM, no Git clone - php container handles that)
echo "🚀 [staging-nginx] Starting nginx..."
exec nginx -g "daemon off;"
labels:
- "traefik.enable=true"
# HTTP Router for staging subdomain
- "traefik.http.routers.staging.rule=Host(`staging.michaelschiemer.de`)"
- "traefik.http.routers.staging.entrypoints=websecure"
- "traefik.http.routers.staging.tls=true"
- "traefik.http.routers.staging.tls.certresolver=letsencrypt"
# Service
- "traefik.http.services.staging.loadbalancer.server.port=80"
# Network
- "traefik.docker.network=traefik-public"
healthcheck:
# Use /health/live endpoint for lightweight liveness check
test: ["CMD-SHELL", "curl -sf http://127.0.0.1/health/live || exit 1"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
depends_on:
php:
condition: service_started
# Remove base service dependencies and build
ports: []
# Redis Cache/Session/Queue Backend (separate from production)
redis:
image: redis:7-alpine
container_name: redis
restart: unless-stopped
networks:
- app-backend
environment:
- TZ=Europe/Berlin
- REDIS_PASSWORD_FILE=/run/secrets/redis_password
secrets:
- redis_password
# Use entrypoint script to inject password from Docker Secret into config
# Note: Script runs as root to read Docker Secrets, then starts Redis
entrypoint: ["/bin/sh", "-c"]
command:
- |
# Read password from Docker Secret (as root)
REDIS_PASSWORD=$$(cat /run/secrets/redis_password 2>/dev/null || echo '')
# Start Redis with all settings as command line arguments (no config file to avoid conflicts)
if [ -n "$$REDIS_PASSWORD" ]; then
exec redis-server \
--bind 0.0.0.0 \
--dir /data \
--maxmemory 256mb \
--maxmemory-policy allkeys-lru \
--save 900 1 \
--save 300 10 \
--save 60 10000 \
--appendonly yes \
--appendfsync everysec \
--requirepass "$$REDIS_PASSWORD"
else
exec redis-server \
--bind 0.0.0.0 \
--dir /data \
--maxmemory 256mb \
--maxmemory-policy allkeys-lru \
--save 900 1 \
--save 300 10 \
--save 60 10000 \
--appendonly yes \
--appendfsync everysec
fi
volumes:
- redis-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
# Queue Worker (Background Jobs)
queue-worker:
image: localhost:5000/framework:latest
container_name: queue-worker
restart: unless-stopped
networks:
- app-backend
- app-internal
environment:
- TZ=Europe/Berlin
- APP_ENV=staging
- APP_DEBUG=false
# Database - using separate staging database
- DB_HOST=postgres
- DB_PORT=5432
- DB_DATABASE=${DB_DATABASE:-michaelschiemer_staging}
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
# Use Docker Secrets via *_FILE pattern (Framework supports this automatically)
- DB_PASSWORD_FILE=/run/secrets/db_user_password
# Redis
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD_FILE=/run/secrets/redis_password
# Queue
- QUEUE_DRIVER=redis
- QUEUE_CONNECTION=default
- QUEUE_WORKER_SLEEP=${QUEUE_WORKER_SLEEP:-3}
- QUEUE_WORKER_TRIES=${QUEUE_WORKER_TRIES:-3}
- QUEUE_WORKER_TIMEOUT=${QUEUE_WORKER_TIMEOUT:-60}
volumes:
- app-code:/var/www/html
- app-storage:/var/www/html/storage
- app-logs:/var/www/html/storage/logs
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
command: php console.php queue:work --queue=default --timeout=${QUEUE_WORKER_TIMEOUT:-60}
healthcheck:
test: ["CMD-SHELL", "php -r 'exit(0);' && test -f /var/www/html/console.php || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
depends_on:
php:
condition: service_started
redis:
condition: service_started
entrypoint: ""
stop_grace_period: 30s
secrets:
- db_user_password
- redis_password
- app_key
# Scheduler (Cron Jobs)
scheduler:
image: localhost:5000/framework:latest
container_name: scheduler
restart: unless-stopped
networks:
- app-backend
- app-internal
environment:
- TZ=Europe/Berlin
- APP_ENV=staging
- APP_DEBUG=false
# Database - using separate staging database
- DB_HOST=postgres
- DB_PORT=5432
- DB_DATABASE=${DB_DATABASE:-michaelschiemer_staging}
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
# Use Docker Secrets via *_FILE pattern (Framework supports this automatically)
- DB_PASSWORD_FILE=/run/secrets/db_user_password
# Redis
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD_FILE=/run/secrets/redis_password
volumes:
- app-code:/var/www/html
- app-storage:/var/www/html/storage
- app-logs:/var/www/html/storage/logs
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
command: php console.php scheduler:run
healthcheck:
test: ["CMD-SHELL", "php -r 'exit(0);' && test -f /var/www/html/console.php || exit 1"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
depends_on:
php:
condition: service_started
redis:
condition: service_started
entrypoint: ""
stop_grace_period: 30s
secrets:
- db_user_password
- redis_password
- app_key
# Disable base services (override from docker-compose.base.yml)
web:
profiles: [never]
minio:
profiles: [never]
networks:
traefik-public:
external: true
app-backend:
driver: bridge
app-internal:
external: true
name: app-internal
volumes:
app-code:
name: staging-code
app-storage:
name: staging-storage
app-logs:
name: staging-logs
redis-data:
name: staging-redis-data
# Docker Secrets Configuration
# Secrets are inherited from docker-compose.base.yml
# But we need to explicitly define them here to ensure they're available
secrets:
db_user_password:
file: ./deployment/secrets/staging/db_password.txt
external: false
redis_password:
file: ./deployment/secrets/staging/redis_password.txt
external: false
app_key:
file: ./deployment/secrets/staging/app_key.txt
external: false