feat(deploy): add Gitea CI/CD workflows and production Docker Compose config

- Add staging deployment workflow (deploy-staging.yml)
- Add production deployment workflow (deploy-production.yml)
- Add workflow documentation (README.md)
- Add secrets setup guide (SECRETS_SETUP_GUIDE.md)
- Add production Docker Compose configuration (docker-compose.prod.yml)

Workflows implement automated deployment with SSH-based remote execution,
health checks, rollback on failure, and smoke testing.
This commit is contained in:
2025-11-24 18:37:27 +01:00
parent 8f3c15ddbb
commit 4eb7134853
5 changed files with 2230 additions and 0 deletions

484
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,484 @@
# Production Environment Override
# Usage: docker-compose -f docker-compose.base.yml -f docker-compose.prod.yml up
#
# This file configures services for production deployment:
# - Production-specific service names (production-*)
# - Private registry images (localhost:5000/framework:latest)
# - Git-based code deployment from main branch
# - Traefik integration for michaelschiemer.de domain
# - PostgreSQL connection via postgres-production-internal network
# - Production-grade resource limits and security settings
# - Docker Secrets for sensitive configuration
services:
production-app:
image: localhost:5000/framework:latest
container_name: production-app
restart: unless-stopped
entrypoint: >
sh -c '
set -e
echo "[Production Entrypoint] Starting initialization..."
# Copy Docker Secrets to /tmp for permission workaround
if [ -f /run/secrets/db_user_password ]; then
cp /run/secrets/db_user_password /tmp/db_user_password
chmod 644 /tmp/db_user_password
export DB_PASSWORD_FILE=/tmp/db_user_password
fi
if [ -f /run/secrets/redis_password ]; then
cp /run/secrets/redis_password /tmp/redis_password
chmod 644 /tmp/redis_password
export REDIS_PASSWORD_FILE=/tmp/redis_password
fi
if [ -f /run/secrets/app_key ]; then
cp /run/secrets/app_key /tmp/app_key
chmod 644 /tmp/app_key
export APP_KEY_FILE=/tmp/app_key
fi
if [ -f /run/secrets/vault_encryption_key ]; then
cp /run/secrets/vault_encryption_key /tmp/vault_encryption_key
chmod 644 /tmp/vault_encryption_key
export VAULT_ENCRYPTION_KEY_FILE=/tmp/vault_encryption_key
fi
if [ -f /run/secrets/git_token ]; then
cp /run/secrets/git_token /tmp/git_token
chmod 644 /tmp/git_token
GIT_TOKEN=$(cat /tmp/git_token)
export GIT_TOKEN
fi
# Git deployment with authentication
if [ -n "$GIT_REPOSITORY_URL" ] && [ -n "$GIT_TOKEN" ]; then
echo "[Production Entrypoint] Configuring Git deployment..."
# Configure Git credentials
git config --global credential.helper store
echo "https://oauth2:${GIT_TOKEN}@git.michaelschiemer.de" > ~/.git-credentials
cd /var/www/html
# Clone repository if not exists
if [ ! -d .git ]; then
echo "[Production Entrypoint] Cloning repository..."
git clone --branch ${GIT_BRANCH:-main} ${GIT_REPOSITORY_URL} /tmp/repo
mv /tmp/repo/.git .
git reset --hard HEAD
else
echo "[Production Entrypoint] Pulling latest changes..."
git fetch origin ${GIT_BRANCH:-main}
git reset --hard origin/${GIT_BRANCH:-main}
fi
echo "[Production Entrypoint] Git deployment completed"
else
echo "[Production Entrypoint] Git deployment skipped (no repository configured)"
fi
# Install/Update Composer dependencies (production mode)
if [ -f composer.json ]; then
echo "[Production Entrypoint] Installing Composer dependencies (production mode)..."
composer install --no-dev --optimize-autoloader --no-interaction --no-progress
fi
# Run database migrations
if [ -f console.php ]; then
echo "[Production Entrypoint] Running database migrations..."
php console.php db:migrate --force || echo "[Production Entrypoint] Migration failed or no migrations pending"
fi
# Warm up caches
if [ -f console.php ]; then
echo "[Production Entrypoint] Warming up caches..."
php console.php cache:warm || echo "[Production Entrypoint] Cache warm-up skipped"
fi
# Set proper permissions
chown -R www-data:www-data /var/www/html/storage /var/www/html/var || true
chmod -R 775 /var/www/html/storage /var/www/html/var || true
echo "[Production Entrypoint] Initialization complete, starting PHP-FPM..."
exec php-fpm
'
environment:
- APP_ENV=production
- APP_DEBUG=false
- APP_NAME=${APP_NAME:-Michael Schiemer}
- APP_TIMEZONE=${APP_TIMEZONE:-Europe/Berlin}
- APP_LOCALE=${APP_LOCALE:-de}
- APP_URL=https://michaelschiemer.de
- FORCE_HTTPS=true
- GIT_REPOSITORY_URL=${GIT_REPOSITORY_URL:-https://git.michaelschiemer.de/michael/framework.git}
- GIT_BRANCH=${GIT_BRANCH:-main}
- DB_DRIVER=pgsql
- DB_HOST=${DB_HOST:-postgres-production}
- DB_PORT=5432
- DB_DATABASE=${DB_DATABASE:-michaelschiemer_production}
- DB_USERNAME=${DB_USERNAME:-postgres}
- DB_PASSWORD_FILE=/tmp/db_user_password
- REDIS_HOST=production-redis
- REDIS_PORT=6379
- REDIS_PASSWORD_FILE=/tmp/redis_password
- APP_KEY_FILE=/tmp/app_key
- VAULT_ENCRYPTION_KEY_FILE=/tmp/vault_encryption_key
- OPCACHE_ENABLED=true
- ANALYTICS_ENABLED=true
- ANALYTICS_TRACK_PERFORMANCE=false
- SESSION_FINGERPRINT_STRICT=true
- ADMIN_ALLOWED_IPS=${ADMIN_ALLOWED_IPS:-127.0.0.1,::1}
- COMPOSE_PROJECT_NAME=framework-production
volumes:
- production-code:/var/www/html
- production-storage:/var/www/html/storage
- production-logs:/var/www/html/storage/logs
- composer-cache:/root/.composer/cache
secrets:
- db_user_password
- redis_password
- app_key
- vault_encryption_key
- git_token
networks:
- production-internal
- postgres-production-internal
healthcheck:
test: ["CMD", "php", "-v"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
memory: 1G
cpus: '2.0'
reservations:
memory: 512M
cpus: '1.0'
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
production-nginx:
image: nginx:alpine
container_name: production-nginx
restart: unless-stopped
depends_on:
production-app:
condition: service_healthy
volumes:
- production-code:/var/www/html:ro
- ./docker/nginx/production.conf:/etc/nginx/conf.d/default.conf:ro
- production-logs:/var/www/html/storage/logs
networks:
- production-internal
- traefik-public
labels:
# Traefik Configuration
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
# Primary Domain Router (HTTPS)
- "traefik.http.routers.production.rule=Host(`michaelschiemer.de`) || Host(`www.michaelschiemer.de`)"
- "traefik.http.routers.production.entrypoints=websecure"
- "traefik.http.routers.production.tls=true"
- "traefik.http.routers.production.tls.certresolver=letsencrypt"
- "traefik.http.routers.production.service=production"
# HTTP to HTTPS Redirect
- "traefik.http.routers.production-http.rule=Host(`michaelschiemer.de`) || Host(`www.michaelschiemer.de`)"
- "traefik.http.routers.production-http.entrypoints=web"
- "traefik.http.routers.production-http.middlewares=production-redirect-https"
# Middlewares
- "traefik.http.middlewares.production-redirect-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.production-redirect-https.redirectscheme.permanent=true"
# WWW to non-WWW redirect
- "traefik.http.middlewares.production-redirect-www.redirectregex.regex=^https://www\\.michaelschiemer\\.de/(.*)"
- "traefik.http.middlewares.production-redirect-www.redirectregex.replacement=https://michaelschiemer.de/$${1}"
- "traefik.http.middlewares.production-redirect-www.redirectregex.permanent=true"
# Security Headers
- "traefik.http.middlewares.production-security-headers.headers.stsSeconds=31536000"
- "traefik.http.middlewares.production-security-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.production-security-headers.headers.stsPreload=true"
- "traefik.http.middlewares.production-security-headers.headers.forceSTSHeader=true"
- "traefik.http.middlewares.production-security-headers.headers.frameDeny=true"
- "traefik.http.middlewares.production-security-headers.headers.contentTypeNosniff=true"
- "traefik.http.middlewares.production-security-headers.headers.browserXssFilter=true"
- "traefik.http.middlewares.production-security-headers.headers.referrerPolicy=strict-origin-when-cross-origin"
# Apply middleware chain
- "traefik.http.routers.production.middlewares=production-redirect-www,production-security-headers,production-rate-limit"
# Rate Limiting
- "traefik.http.middlewares.production-rate-limit.ratelimit.average=100"
- "traefik.http.middlewares.production-rate-limit.ratelimit.burst=200"
- "traefik.http.middlewares.production-rate-limit.ratelimit.period=1s"
# Service Configuration
- "traefik.http.services.production.loadbalancer.server.port=80"
- "traefik.http.services.production.loadbalancer.healthcheck.path=/health"
- "traefik.http.services.production.loadbalancer.healthcheck.interval=30s"
- "traefik.http.services.production.loadbalancer.healthcheck.timeout=5s"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
reservations:
memory: 256M
cpus: '0.5'
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
production-redis:
image: redis:7-alpine
container_name: production-redis
restart: unless-stopped
entrypoint: >
sh -c '
set -e
if [ -f /run/secrets/redis_password ]; then
cp /run/secrets/redis_password /tmp/redis_password
chmod 644 /tmp/redis_password
REDIS_PASSWORD=$(cat /tmp/redis_password)
exec redis-server \
--requirepass "$REDIS_PASSWORD" \
--maxmemory 512mb \
--maxmemory-policy allkeys-lru \
--save 900 1 \
--save 300 10 \
--save 60 10000 \
--appendonly yes \
--appendfsync everysec
else
echo "ERROR: Redis password secret not found"
exit 1
fi
'
volumes:
- production-redis-data:/data
secrets:
- redis_password
networks:
- production-internal
healthcheck:
test: ["CMD", "sh", "-c", "redis-cli --no-auth-warning -a $(cat /tmp/redis_password 2>/dev/null || echo '') ping || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 512M
cpus: '1.0'
reservations:
memory: 256M
cpus: '0.5'
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
production-queue-worker:
image: localhost:5000/framework:latest
container_name: production-queue-worker
restart: unless-stopped
entrypoint: >
sh -c '
set -e
echo "[Queue Worker] Starting initialization..."
# Copy Docker Secrets
if [ -f /run/secrets/db_user_password ]; then
cp /run/secrets/db_user_password /tmp/db_user_password
chmod 644 /tmp/db_user_password
export DB_PASSWORD_FILE=/tmp/db_user_password
fi
if [ -f /run/secrets/redis_password ]; then
cp /run/secrets/redis_password /tmp/redis_password
chmod 644 /tmp/redis_password
export REDIS_PASSWORD_FILE=/tmp/redis_password
fi
echo "[Queue Worker] Starting worker process..."
exec php /var/www/html/worker.php
'
environment:
- APP_ENV=production
- APP_DEBUG=false
- DB_HOST=${DB_HOST:-postgres-production}
- DB_PORT=5432
- DB_DATABASE=${DB_DATABASE:-michaelschiemer_production}
- DB_USERNAME=${DB_USERNAME:-postgres}
- DB_PASSWORD_FILE=/tmp/db_user_password
- REDIS_HOST=production-redis
- REDIS_PORT=6379
- REDIS_PASSWORD_FILE=/tmp/redis_password
- WORKER_SLEEP_TIME=${WORKER_SLEEP_TIME:-100000}
- WORKER_MAX_JOBS=${WORKER_MAX_JOBS:-10000}
volumes:
- production-code:/var/www/html:ro
- production-storage:/var/www/html/storage
- production-logs:/var/www/html/storage/logs
secrets:
- db_user_password
- redis_password
networks:
- production-internal
- postgres-production-internal
depends_on:
production-app:
condition: service_healthy
production-redis:
condition: service_healthy
stop_grace_period: 30s
deploy:
resources:
limits:
memory: 1G
cpus: '1.0'
reservations:
memory: 512M
cpus: '0.5'
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
production-scheduler:
image: localhost:5000/framework:latest
container_name: production-scheduler
restart: unless-stopped
entrypoint: >
sh -c '
set -e
echo "[Scheduler] Starting initialization..."
# Copy Docker Secrets
if [ -f /run/secrets/db_user_password ]; then
cp /run/secrets/db_user_password /tmp/db_user_password
chmod 644 /tmp/db_user_password
export DB_PASSWORD_FILE=/tmp/db_user_password
fi
if [ -f /run/secrets/redis_password ]; then
cp /run/secrets/redis_password /tmp/redis_password
chmod 644 /tmp/redis_password
export REDIS_PASSWORD_FILE=/tmp/redis_password
fi
echo "[Scheduler] Starting scheduler process..."
exec php /var/www/html/scheduler.php
'
environment:
- APP_ENV=production
- APP_DEBUG=false
- DB_HOST=${DB_HOST:-postgres-production}
- DB_PORT=5432
- DB_DATABASE=${DB_DATABASE:-michaelschiemer_production}
- DB_USERNAME=${DB_USERNAME:-postgres}
- DB_PASSWORD_FILE=/tmp/db_user_password
- REDIS_HOST=production-redis
- REDIS_PORT=6379
- REDIS_PASSWORD_FILE=/tmp/redis_password
volumes:
- production-code:/var/www/html:ro
- production-storage:/var/www/html/storage
- production-logs:/var/www/html/storage/logs
secrets:
- db_user_password
- redis_password
networks:
- production-internal
- postgres-production-internal
depends_on:
production-app:
condition: service_healthy
production-redis:
condition: service_healthy
stop_grace_period: 30s
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# Disable base services (override from docker-compose.base.yml)
web:
profiles: [never]
php:
profiles: [never]
php-test:
profiles: [never]
redis:
profiles: [never]
queue-worker:
profiles: [never]
minio:
profiles: [never]
# Networks
networks:
production-internal:
driver: bridge
internal: false
postgres-production-internal:
external: true
name: postgres-production-internal
traefik-public:
external: true
name: traefik-public
# Volumes
volumes:
production-code:
driver: local
production-storage:
driver: local
production-logs:
driver: local
production-redis-data:
driver: local
composer-cache:
driver: local