Deploy automation: 8-step pipeline with volume mounting, multi-container docker-compose

This commit is contained in:
Agent Zero
2026-07-25 22:48:38 +02:00
parent 20e6545aa1
commit 745b634e7c
2 changed files with 132 additions and 70 deletions
+53 -56
View File
@@ -1,27 +1,30 @@
# ============================================================================= # =============================================================================
# docker-compose.yml — CRM System v1.0 (local testing with PostgreSQL) # docker-compose.yml — LeoCRM Multi-Container Setup
# #
# Use this file to run a full Postgres + crm-app stack locally, e.g. for # Full stack: PostgreSQL + Redis + App + Worker, all with persistent volumes.
# smoke-testing the Docker image or running the backend against a real
# PostgreSQL instance before deploying to Coolify.
# #
# Production deploys in Coolify use COOLIFY_SETUP.md (single-container app + # This file serves two purposes:
# a Coolify-managed Postgres database), NOT this file. # 1. Local development / testing: `docker compose up --build`
# 2. Reference for the production multi-container architecture
# #
# Usage: # Production deploys via Coolify use scripts/deploy.py which automates:
# - Coolify API build & deploy
# - Persistent volume mounting (patched into Coolify's generated compose)
# - Worker container startup
# - RLS policy enforcement
# - Health & domain verification
#
# Usage (local testing):
# cp .env.docker.example .env.docker # cp .env.docker.example .env.docker
# $EDITOR .env.docker # fill AUTH_SECRET, POSTGRES_PASSWORD, ... # $EDITOR .env.docker # fill SECRET_KEY, POSTGRES_PASSWORD, ...
# docker compose --env-file .env.docker up --build # docker compose --env-file .env.docker up --build
# curl http://localhost:8000/health # curl http://localhost:8000/api/v1/health
# ============================================================================= # =============================================================================
services: services:
# ------------------------------------------------------------------------- # ── PostgreSQL 16 with pgvector ─────────────────────────────────────
# PostgreSQL 16 (Alpine) — local Postgres for development / smoke tests.
# Coolify will provision its own managed Postgres database in production.
# -------------------------------------------------------------------------
postgres: postgres:
image: postgres:16-alpine image: pgvector/pgvector:pg16
container_name: crm-postgres container_name: crm-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -32,7 +35,7 @@ services:
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
ports: ports:
- "5432:5432" # local-only convenience; remove for CI / prod-like runs - "5432:5432" # local-only; remove for prod-like runs
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-crm_user} -d ${POSTGRES_DB:-crm_db}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-crm_user} -d ${POSTGRES_DB:-crm_db}"]
interval: 10s interval: 10s
@@ -42,10 +45,25 @@ services:
networks: networks:
- crm-net - crm-net
# ------------------------------------------------------------------------- # ── Redis 7 (sessions, rate limiting, ARQ queue) ────────────────────
# CRM App — built from the local Dockerfile. redis:
# NOTE: no `image:` directive — we build from source on `docker compose up`. image: redis:7-alpine
# ------------------------------------------------------------------------- container_name: crm-redis
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD:-changeme}
volumes:
- redisdata:/data
ports:
- "6379:6379" # local-only; remove for prod-like runs
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-changeme}", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- crm-net
# ── CRM API (FastAPI + Uvicorn) ─────────────────────────────────────
crm-app: crm-app:
build: build:
context: . context: .
@@ -58,17 +76,23 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
environment: environment:
# Use the internal docker-compose DNS name "postgres" (NOT localhost)
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD:-changeme}@redis:6379/0} REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD:-changeme}@redis:6379/0}
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required (min 32 chars)} SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required (min 32 chars)}
# Frontend served from same origin in production; allow local dev hosts too
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8000,http://localhost:5173} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8000,http://localhost:5173}
ENVIRONMENT: ${ENVIRONMENT:-production} ENVIRONMENT: ${ENVIRONMENT:-production}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true} SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
STORAGE_PATH: ${STORAGE_PATH:-/data/storage} STORAGE_PATH: ${STORAGE_PATH:-/data/storage}
BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12} BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12}
# S3 Storage (optional — if STORAGE_BACKEND=s3)
STORAGE_BACKEND: ${STORAGE_BACKEND:-local}
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_BUCKET: ${S3_BUCKET:-}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
S3_REGION: ${S3_REGION:-us-east-1}
S3_SECURE: ${S3_SECURE:-true}
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
@@ -82,61 +106,34 @@ services:
networks: networks:
- crm-net - crm-net
# ------------------------------------------------------------------------- # ── CRM Worker (ARQ background jobs, cron, outbox processor) ───────
# CRM Worker — ARQ background worker (same image, different entrypoint).
# Runs migrations? No — the API container handles migrations.
# Scale with `docker compose up --scale crm-worker=N`.
# Cron jobs use a Redis-based distributed lock so only one replica fires.
# -------------------------------------------------------------------------
crm-worker: crm-worker:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: crm-worker container_name: crm-worker
restart: unless-stopped restart: unless-stopped
entrypoint: ["/app/worker.sh"]
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
entrypoint: ["/app/worker.sh"]
environment: environment:
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD:-changeme}@redis:6379/0} REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD:-changeme}@redis:6379/0}
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required (min 32 chars)} SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required (min 32 chars)}
ENVIRONMENT: ${ENVIRONMENT:-production} ENVIRONMENT: ${ENVIRONMENT:-production}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-true}
STORAGE_PATH: ${STORAGE_PATH:-/data/storage} STORAGE_PATH: ${STORAGE_PATH:-/data/storage}
BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12} STORAGE_BACKEND: ${STORAGE_BACKEND:-local}
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_BUCKET: ${S3_BUCKET:-}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
volumes: volumes:
- storage:/data/storage - storage:/data/storage
healthcheck:
# Check if the ARQ worker process is alive
test: ["CMD-SHELL", "pgrep -f \"arq app.core.worker.WorkerSettings\" || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
networks:
- crm-net
# -------------------------------------------------------------------------
# Redis 7 (Alpine) — sessions, rate limiting, ARQ queue.
# -------------------------------------------------------------------------
redis:
image: redis:7-alpine
container_name: crm-redis
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD:-changeme}
volumes:
- redisdata:/data
ports:
- "6379:6379" # local-only convenience; remove for prod-like runs
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-changeme}", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks: networks:
- crm-net - crm-net
Executable → Regular
+79 -14
View File
@@ -280,6 +280,61 @@ def verify_db() -> DeployResult:
return DeployResult(all_pass, "DB verification " + ("passed" if all_pass else "failed"), 0, details) return DeployResult(all_pass, "DB verification " + ("passed" if all_pass else "failed"), 0, details)
def ensure_volume() -> DeployResult:
"""Ensure persistent volume is mounted on the app container.
Coolify regenerates docker-compose.yaml on each deploy, overwriting manual
volume config. This function patches the generated compose file to add the
volume, then restarts the container.
"""
print(" Ensuring persistent volume...")
# Ensure volume exists
code, output = ssh_run("docker volume inspect leocrm-storage >/dev/null 2>&1 || docker volume create leocrm-storage")
if code != 0:
return DeployResult(False, f"Failed to ensure volume: {output}")
# Patch the Coolify-generated docker-compose.yaml to add volume
patch_script = '''python3 -c "
import sys
path = '/data/coolify/applications/stvabl4vaqru7jclx4ittzr3/docker-compose.yaml'
with open(path) as f:
content = f.read()
# Add volume to service section (after env_file)
old = ' env_file:\\n - .env\\n'
new = ' env_file:\\n - .env\\n volumes:\\n - leocrm-storage:/data/storage\\n'
if 'leocrm-storage:/data/storage' not in content:
content = content.replace(old, new, 1)
# Add top-level volumes section if missing
if 'volumes:' not in content.split('networks:')[0] if 'networks:' in content else True:
if 'volumes:\\n leocrm-storage:' not in content:
content += '\\nvolumes:\\n leocrm-storage:\\n external: true\\n'
with open(path, 'w') as f:
f.write(content)
print('Volume patched')
"'''
code, output = ssh_run(patch_script)
if code != 0:
return DeployResult(False, f"Failed to patch compose file: {output}")
# Restart container with volume
code, output = ssh_run("cd /data/coolify/applications/stvabl4vaqru7jclx4ittzr3/ && docker compose down 2>&1 && docker compose up -d 2>&1")
if code != 0:
return DeployResult(False, f"Failed to restart with volume: {output}")
# Verify volume is mounted
import time as _time
_time.sleep(5)
code, output = ssh_run('docker inspect $(docker ps --format "{{.Names}}" | grep stvabl4 | head -1) --format "{{json .Mounts}}" 2>/dev/null')
if code == 0 and "leocrm-storage" in output:
return DeployResult(True, "Persistent volume mounted")
return DeployResult(False, f"Volume not found in mounts: {output}")
def ensure_rls() -> DeployResult: def ensure_rls() -> DeployResult:
"""Ensure RLS is active on all tenant tables (idempotent).""" """Ensure RLS is active on all tenant tables (idempotent)."""
print(" Ensuring RLS on all tenant tables...") print(" Ensuring RLS on all tenant tables...")
@@ -336,7 +391,7 @@ def deploy(environment: str = "production", skip_build: bool = False, migrate_on
# Step 1: Trigger Coolify deploy # Step 1: Trigger Coolify deploy
if not skip_build and not migrate_only: if not skip_build and not migrate_only:
print("\n[1/7] Triggering Coolify build & deploy...") print("\n[1/8] Triggering Coolify build & deploy...")
try: try:
result = client.deploy(APP_UUID) result = client.deploy(APP_UUID)
deploy_info = result["deployments"][0] deploy_info = result["deployments"][0]
@@ -357,9 +412,19 @@ def deploy(environment: str = "production", skip_build: bool = False, migrate_on
print("\n[1/7] Skipping build (skip-build flag)") print("\n[1/7] Skipping build (skip-build flag)")
steps.append(("Coolify deploy", DeployResult(True, "Skipped"))) steps.append(("Coolify deploy", DeployResult(True, "Skipped")))
# Step 2: Wait for healthy container # Step 2: Ensure persistent volume
if not migrate_only: if not migrate_only:
print("\n[2/7] Waiting for container health...") print("\n[2/8] Ensuring persistent volume...")
vol_result = ensure_volume()
steps.append(("Persistent volume", vol_result))
if not vol_result.success:
print(f" ⚠️ {vol_result.message} (non-fatal)")
else:
print(f"{vol_result.message}")
# Step 3: Wait for healthy container
if not migrate_only:
print("\n[3/8] Waiting for container health...")
health_result = wait_for_healthy(timeout=120) health_result = wait_for_healthy(timeout=120)
steps.append(("Container health", health_result)) steps.append(("Container health", health_result))
if not health_result.success: if not health_result.success:
@@ -367,8 +432,8 @@ def deploy(environment: str = "production", skip_build: bool = False, migrate_on
return 1 return 1
print(f"{health_result.message}") print(f"{health_result.message}")
# Step 3: Ensure RLS # Step 4: Ensure RLS
print("\n[3/7] Ensuring RLS policies...") print("\n[4/8] Ensuring RLS policies...")
rls_result = ensure_rls() rls_result = ensure_rls()
steps.append(("RLS policies", rls_result)) steps.append(("RLS policies", rls_result))
if not rls_result.success: if not rls_result.success:
@@ -376,8 +441,8 @@ def deploy(environment: str = "production", skip_build: bool = False, migrate_on
else: else:
print(f"{rls_result.message}") print(f"{rls_result.message}")
# Step 4: Verify DB # Step 5: Verify DB
print("\n[4/7] Verifying database...") print("\n[5/8] Verifying database...")
db_result = verify_db() db_result = verify_db()
steps.append(("DB verification", db_result)) steps.append(("DB verification", db_result))
if db_result.success: if db_result.success:
@@ -387,9 +452,9 @@ def deploy(environment: str = "production", skip_build: bool = False, migrate_on
else: else:
print(f" ⚠️ {db_result.message}") print(f" ⚠️ {db_result.message}")
# Step 5: Start worker # Step 6: Start worker
if not migrate_only: if not migrate_only:
print("\n[5/7] Starting worker container...") print("\n[6/8] Starting worker container...")
worker_result = start_worker() worker_result = start_worker()
steps.append(("Worker", worker_result)) steps.append(("Worker", worker_result))
if not worker_result.success: if not worker_result.success:
@@ -397,8 +462,8 @@ def deploy(environment: str = "production", skip_build: bool = False, migrate_on
else: else:
print(f"{worker_result.message}") print(f"{worker_result.message}")
# Step 6: Verify health # Step 7: Verify health
print("\n[6/7] Verifying app health...") print("\n[7/8] Verifying app health...")
app_health = verify_health() app_health = verify_health()
steps.append(("App health", app_health)) steps.append(("App health", app_health))
if not app_health.success: if not app_health.success:
@@ -406,8 +471,8 @@ def deploy(environment: str = "production", skip_build: bool = False, migrate_on
return 1 return 1
print(f"{app_health.message}") print(f"{app_health.message}")
# Step 7: Verify domain # Step 8: Verify domain
print("\n[7/7] Verifying domain...") print("\n[8/8] Verifying domain...")
domain_result = verify_domain(domain) domain_result = verify_domain(domain)
steps.append(("Domain", domain_result)) steps.append(("Domain", domain_result))
if domain_result.success: if domain_result.success:
@@ -424,7 +489,7 @@ def deploy(environment: str = "production", skip_build: bool = False, migrate_on
print(f" {status} {name}: {result.message}") print(f" {status} {name}: {result.message}")
# Return success if all critical steps passed # Return success if all critical steps passed
critical = [s for s in steps if s[0] in ("Coolify deploy", "Container health", "App health")] critical = [s for s in steps if s[0] in ("Coolify deploy", "Container health", "App health", "Persistent volume")]
all_critical = all(s[1].success for s in critical) all_critical = all(s[1].success for s in critical)
print(f"\n Overall: {'✅ SUCCESS' if all_critical else '❌ FAILED'}\n") print(f"\n Overall: {'✅ SUCCESS' if all_critical else '❌ FAILED'}\n")
return 0 if all_critical else 1 return 0 if all_critical else 1