2026-07-25 22:42:05 +02:00
#!/usr/bin/env python3
""" Automated deployment script for LeoCRM via Coolify API.
Usage:
python scripts/deploy.py # Deploy to production
python scripts/deploy.py --environment test # Deploy to test environment
python scripts/deploy.py --skip-build # Skip build, just restart
python scripts/deploy.py --migrate-only # Only run migrations
Features:
- Triggers Coolify build & deploy via API
- Waits for healthy status
- Starts worker container automatically
- Verifies RLS, migrations, and health
- Supports test and production environments
- No manual steps required
Environment variables (set in .env or shell):
COOLIFY_API_TOKEN — Coolify API token
COOLIFY_APP_UUID — Application UUID for the app
COOLIFY_BASE_URL — Coolify base URL (default: https://server.media-on.de)
SSH_KEY — SSH key path for server access
SERVER_IP — Server IP for SSH access
# Worker (optional — if not set, worker is skipped)
WORKER_APP_UUID — Separate Coolify app UUID for worker (optional)
# S3 Storage (optional — if not set, local storage is used)
S3_ENDPOINT — S3-compatible endpoint
S3_BUCKET — Bucket name
S3_ACCESS_KEY — Access key
S3_SECRET_KEY — Secret key
Exit codes:
0 — deployment successful
1 — deployment failed
2 — configuration error
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
from dataclasses import dataclass
from typing import Any
import httpx
# ─── Configuration ────────────────────────────────────────────────────
COOLIFY_BASE_URL = os . environ . get ( " COOLIFY_BASE_URL " , " https://server.media-on.de " )
COOLIFY_TOKEN = os . environ . get ( " COOLIFY_API_TOKEN " , " " )
APP_UUID = os . environ . get ( " COOLIFY_APP_UUID " , " stvabl4vaqru7jclx4ittzr3 " )
SSH_KEY = os . environ . get ( " SSH_KEY " , " /a0/usr/workdir/.ssh/coolify-01-root " )
SERVER_IP = os . environ . get ( " SERVER_IP " , " 46.225.91.159 " )
# Worker configuration
WORKER_APP_UUID = os . environ . get ( " WORKER_APP_UUID " , " " ) # Empty = use SSH-based worker
WORKER_CONTAINER_NAME = " leocrm-worker "
# ─── Data Structures ──────────────────────────────────────────────────
@dataclass
class DeployResult :
success : bool
message : str
duration_s : float = 0.0
details : dict [ str , Any ] = None
# ─── Coolify API Client ────────────────────────────────────────────────
class CoolifyClient :
""" Client for Coolify API operations. """
def __init__ ( self , base_url : str , token : str ) :
self . base_url = base_url . rstrip ( " / " )
self . headers = { " Authorization " : f " Bearer { token } " , " Content-Type " : " application/json " }
def deploy ( self , app_uuid : str ) - > dict [ str , Any ] :
""" Trigger a deployment for an application. """
resp = httpx . post ( f " { self . base_url } /api/v1/deploy " , headers = self . headers , json = { " uuid " : app_uuid } , timeout = 30 )
resp . raise_for_status ( )
return resp . json ( )
def get_app ( self , app_uuid : str ) - > dict [ str , Any ] :
""" Get application details. """
resp = httpx . get ( f " { self . base_url } /api/v1/applications/ { app_uuid } " , headers = self . headers , timeout = 30 )
resp . raise_for_status ( )
return resp . json ( )
def get_deployment ( self , deployment_uuid : str ) - > dict [ str , Any ] :
""" Get deployment status. """
resp = httpx . get ( f " { self . base_url } /api/v1/deployments/ { deployment_uuid } " , headers = self . headers , timeout = 30 )
resp . raise_for_status ( )
return resp . json ( )
def update_env ( self , app_uuid : str , envs : list [ dict [ str , str ] ] ) - > dict [ str , Any ] :
""" Bulk update environment variables. """
resp = httpx . patch (
f " { self . base_url } /api/v1/applications/ { app_uuid } /envs/bulk " ,
headers = self . headers ,
json = { " data " : envs } ,
timeout = 30 ,
)
resp . raise_for_status ( )
return resp . json ( )
def get_envs ( self , app_uuid : str ) - > list [ dict [ str , Any ] ] :
""" Get all environment variables. """
resp = httpx . get ( f " { self . base_url } /api/v1/applications/ { app_uuid } /envs " , headers = self . headers , timeout = 30 )
resp . raise_for_status ( )
return resp . json ( )
def set_domain ( self , app_uuid : str , domain : str ) - > dict [ str , Any ] :
""" Set the domain for an application. """
resp = httpx . patch (
f " { self . base_url } /api/v1/applications/ { app_uuid } " ,
headers = self . headers ,
json = { " domains " : domain } ,
timeout = 30 ,
)
resp . raise_for_status ( )
return resp . json ( )
# ─── SSH Helper ────────────────────────────────────────────────────────
def ssh_run ( cmd : str , timeout : int = 60 ) - > tuple [ int , str ] :
""" Run a command on the server via SSH. """
full_cmd = [
" ssh " , " -i " , SSH_KEY ,
" -o " , " StrictHostKeyChecking=no " ,
" -o " , " ConnectTimeout=10 " ,
f " root@ { SERVER_IP } " ,
cmd ,
]
result = subprocess . run ( full_cmd , capture_output = True , text = True , timeout = timeout )
return result . returncode , result . stdout + result . stderr
def scp_upload ( local_path : str , remote_path : str ) - > bool :
""" Upload a file to the server via SCP. """
cmd = [
" scp " , " -i " , SSH_KEY ,
" -o " , " StrictHostKeyChecking=no " ,
local_path , f " root@ { SERVER_IP } : { remote_path } " ,
]
result = subprocess . run ( cmd , capture_output = True , text = True , timeout = 30 )
return result . returncode == 0
# ─── Deploy Steps ──────────────────────────────────────────────────────
def wait_for_deployment ( client : CoolifyClient , deployment_uuid : str , timeout : int = 300 ) - > DeployResult :
""" Wait for a deployment to complete. """
print ( f " Waiting for deployment { deployment_uuid [ : 12 ] } ... " )
start = time . time ( )
while time . time ( ) - start < timeout :
try :
dep = client . get_deployment ( deployment_uuid )
status = dep . get ( " status " , " unknown " )
elapsed = int ( time . time ( ) - start )
print ( f " [ { elapsed } s] Status: { status } " )
if status == " success " :
return DeployResult ( True , " Deployment successful " , time . time ( ) - start , dep )
if status == " failed " :
return DeployResult ( False , f " Deployment failed: { dep . get ( ' message ' , ' unknown ' ) } " , time . time ( ) - start , dep )
except Exception as e :
print ( f " Warning: API error: { e } " )
time . sleep ( 10 )
return DeployResult ( False , f " Deployment timed out after { timeout } s " , time . time ( ) - start )
def wait_for_healthy ( timeout : int = 120 ) - > DeployResult :
""" Wait for the container to become healthy. """
print ( " Waiting for container to become healthy... " )
start = time . time ( )
while time . time ( ) - start < timeout :
code , output = ssh_run ( ' docker ps --format " {{ .Names}} {{ .Status}} " | grep stvabl4 | head -1 ' )
if " healthy " in output :
return DeployResult ( True , " Container is healthy " , time . time ( ) - start )
elapsed = int ( time . time ( ) - start )
print ( f " [ { elapsed } s] { output . strip ( ) } " )
time . sleep ( 5 )
return DeployResult ( False , f " Container did not become healthy in { timeout } s " , time . time ( ) - start )
def start_worker ( image_tag : str | None = None ) - > DeployResult :
""" Start or restart the worker container via SSH. """
print ( " Starting worker container... " )
# Get the latest image tag if not provided
if image_tag is None :
code , output = ssh_run ( ' docker images --format " {{ .Repository}}: {{ .Tag}} " | grep stvabl4 | head -1 ' )
if code != 0 or not output . strip ( ) :
return DeployResult ( False , " Could not find app image " )
image_tag = output . strip ( )
# Get env vars from the app container
code , env_output = ssh_run ( ' docker exec $(docker ps --format " {{ .Names}} " | grep stvabl4 | head -1) env 2>/dev/null | grep -E " ^DATABASE_URL=|^REDIS_URL=|^SECRET_KEY=|^ENVIRONMENT=|^SESSION_COOKIE_SECURE=|^STORAGE_PATH=|^CORS_ORIGINS=|^S3_ " | sort ' )
if code != 0 :
return DeployResult ( False , " Could not get env vars from app container " )
# Build env flags
env_flags = [ ]
for line in env_output . strip ( ) . split ( " \n " ) :
if " = " in line :
key , _ , value = line . partition ( " = " )
env_flags . append ( f ' -e { key } = " { value } " ' )
env_str = " " . join ( env_flags )
# Stop and remove old worker
ssh_run ( f " docker stop { WORKER_CONTAINER_NAME } 2>/dev/null; docker rm { WORKER_CONTAINER_NAME } 2>/dev/null " )
# Start new worker
cmd = f ''' docker run -d --name { WORKER_CONTAINER_NAME } --network coolify --restart unless-stopped { env_str } --entrypoint /app/worker.sh { image_tag } '''
code , output = ssh_run ( cmd )
if code != 0 :
return DeployResult ( False , f " Failed to start worker: { output } " )
# Verify worker is running
time . sleep ( 3 )
code , output = ssh_run ( f ' docker ps --format " {{ {{ .Names }} }} {{ {{ .Status }} }} " | grep { WORKER_CONTAINER_NAME } ' )
if code != 0 :
return DeployResult ( False , " Worker container not running " )
return DeployResult ( True , f " Worker started: { output . strip ( ) } " , 3 )
def verify_health ( ) - > DeployResult :
""" Verify the app is healthy via HTTP. """
print ( " Verifying app health... " )
code , output = ssh_run ( ' docker exec $(docker ps --format " {{ .Names}} " | grep stvabl4 | head -1) curl -s http://localhost:8000/api/v1/health 2>/dev/null ' )
if code == 0 and " healthy " in output :
return DeployResult ( True , f " Health check passed: { output . strip ( ) } " )
return DeployResult ( False , f " Health check failed: { output } " )
def verify_domain ( domain : str ) - > DeployResult :
""" Verify the domain is accessible. """
print ( f " Verifying domain { domain } ... " )
code , output = ssh_run ( f ' curl -s -o /dev/null -w " % {{ http_code }} " { domain } /api/v1/health 2>/dev/null ' )
status = output . strip ( )
if status == " 200 " :
return DeployResult ( True , f " Domain accessible: { status } " )
return DeployResult ( False , f " Domain returned: { status } " )
def verify_db ( ) - > DeployResult :
""" Verify database migrations and RLS. """
print ( " Verifying database... " )
checks = [ ]
# Check alembic version
code , output = ssh_run ( ' docker exec crm-postgres psql -U crm_user -d crm_db -t -c " SELECT version_num FROM alembic_version " 2>/dev/null ' )
version = output . strip ( )
checks . append ( ( " Alembic version " , version , version == " 0040_outbox " ) )
# Check RLS
code , output = ssh_run ( ' docker exec crm-postgres psql -U crm_user -d crm_db -t -c " SELECT count(*) FROM pg_class WHERE relrowsecurity=true AND relforcerowsecurity=true " 2>/dev/null ' )
rls_count = output . strip ( )
checks . append ( ( " RLS tables " , rls_count , int ( rls_count ) > 90 if rls_count . isdigit ( ) else False ) )
# Check event_outbox
code , output = ssh_run ( ' docker exec crm-postgres psql -U crm_user -d crm_db -t -c " SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name= \' event_outbox \' ) " 2>/dev/null ' )
outbox = output . strip ( )
checks . append ( ( " Event outbox " , outbox , " t " in outbox ) )
all_pass = all ( c [ 2 ] for c in checks )
details = { c [ 0 ] : c [ 1 ] for c in checks }
return DeployResult ( all_pass , " DB verification " + ( " passed " if all_pass else " failed " ) , 0 , details )
2026-07-25 22:48:38 +02:00
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 } " )
2026-07-25 22:42:05 +02:00
def ensure_rls ( ) - > DeployResult :
""" Ensure RLS is active on all tenant tables (idempotent). """
print ( " Ensuring RLS on all tenant tables... " )
# Upload RLS fix script
rls_script = """ DO $$
DECLARE t TEXT;
BEGIN
FOR t IN SELECT table_name FROM information_schema.columns WHERE column_name = ' tenant_id ' AND table_schema = ' public ' LOOP
EXECUTE ' ALTER TABLE ' || t || ' ENABLE ROW LEVEL SECURITY ' ;
EXECUTE ' ALTER TABLE ' || t || ' FORCE ROW LEVEL SECURITY ' ;
EXECUTE ' DROP POLICY IF EXISTS tenant_isolation ON ' || t;
EXECUTE ' CREATE POLICY tenant_isolation ON ' || t || ' USING (tenant_id = current_setting( ' ' app.current_tenant_id ' ' )::uuid) WITH CHECK (tenant_id = current_setting( ' ' app.current_tenant_id ' ' )::uuid) ' ;
END LOOP;
END$$;
"""
# Write script locally, upload, execute
import tempfile
with tempfile . NamedTemporaryFile ( mode = " w " , suffix = " .sql " , delete = False ) as f :
f . write ( rls_script )
local_path = f . name
try :
if not scp_upload ( local_path , " /tmp/rls_fix.sql " ) :
return DeployResult ( False , " Failed to upload RLS script " )
code , output = ssh_run ( ' docker exec -i crm-postgres psql -U crm_user -d crm_db < /tmp/rls_fix.sql 2>&1 ' )
if code != 0 and " ERROR " in output :
return DeployResult ( False , f " RLS script failed: { output } " )
return DeployResult ( True , " RLS ensured on all tenant tables " )
finally :
os . unlink ( local_path )
# ─── Main Deploy Pipeline ──────────────────────────────────────────────
def deploy ( environment : str = " production " , skip_build : bool = False , migrate_only : bool = False ) - > int :
""" Run the full deployment pipeline. """
print ( f " \n { ' = ' * 60 } " )
print ( f " LeoCRM Deploy — Environment: { environment } " )
print ( f " { ' = ' * 60 } \n " )
if not COOLIFY_TOKEN :
print ( " ERROR: COOLIFY_API_TOKEN not set " )
return 2
client = CoolifyClient ( COOLIFY_BASE_URL , COOLIFY_TOKEN )
domain = " https://crm.media-on.de " if environment == " production " else " https://crm-test.media-on.de "
steps = [ ]
# Step 1: Trigger Coolify deploy
if not skip_build and not migrate_only :
2026-07-25 22:48:38 +02:00
print ( " \n [1/8] Triggering Coolify build & deploy... " )
2026-07-25 22:42:05 +02:00
try :
result = client . deploy ( APP_UUID )
deploy_info = result [ " deployments " ] [ 0 ]
deploy_uuid = deploy_info [ " deployment_uuid " ]
print ( f " Deploy queued: { deploy_uuid [ : 12 ] } " )
# Wait for deployment
dep_result = wait_for_deployment ( client , deploy_uuid , timeout = 300 )
steps . append ( ( " Coolify deploy " , dep_result ) )
if not dep_result . success :
print ( f " \n ❌ { dep_result . message } " )
return 1
print ( f " ✅ { dep_result . message } " )
except Exception as e :
print ( f " \n ❌ Deploy trigger failed: { e } " )
return 1
else :
print ( " \n [1/7] Skipping build (skip-build flag) " )
steps . append ( ( " Coolify deploy " , DeployResult ( True , " Skipped " ) ) )
2026-07-25 22:48:38 +02:00
# Step 2: Ensure persistent volume
if not migrate_only :
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
2026-07-25 22:42:05 +02:00
if not migrate_only :
2026-07-25 22:48:38 +02:00
print ( " \n [3/8] Waiting for container health... " )
2026-07-25 22:42:05 +02:00
health_result = wait_for_healthy ( timeout = 120 )
steps . append ( ( " Container health " , health_result ) )
if not health_result . success :
print ( f " ❌ { health_result . message } " )
return 1
print ( f " ✅ { health_result . message } " )
2026-07-25 22:48:38 +02:00
# Step 4: Ensure RLS
print ( " \n [4/8] Ensuring RLS policies... " )
2026-07-25 22:42:05 +02:00
rls_result = ensure_rls ( )
steps . append ( ( " RLS policies " , rls_result ) )
if not rls_result . success :
print ( f " ⚠️ { rls_result . message } (non-fatal) " )
else :
print ( f " ✅ { rls_result . message } " )
2026-07-25 22:48:38 +02:00
# Step 5: Verify DB
print ( " \n [5/8] Verifying database... " )
2026-07-25 22:42:05 +02:00
db_result = verify_db ( )
steps . append ( ( " DB verification " , db_result ) )
if db_result . success :
print ( f " ✅ { db_result . message } " )
for k , v in ( db_result . details or { } ) . items ( ) :
print ( f " { k } : { v } " )
else :
print ( f " ⚠️ { db_result . message } " )
2026-07-25 22:48:38 +02:00
# Step 6: Start worker
2026-07-25 22:42:05 +02:00
if not migrate_only :
2026-07-25 22:48:38 +02:00
print ( " \n [6/8] Starting worker container... " )
2026-07-25 22:42:05 +02:00
worker_result = start_worker ( )
steps . append ( ( " Worker " , worker_result ) )
if not worker_result . success :
print ( f " ⚠️ { worker_result . message } (non-fatal) " )
else :
print ( f " ✅ { worker_result . message } " )
2026-07-25 22:48:38 +02:00
# Step 7: Verify health
print ( " \n [7/8] Verifying app health... " )
2026-07-25 22:42:05 +02:00
app_health = verify_health ( )
steps . append ( ( " App health " , app_health ) )
if not app_health . success :
print ( f " ❌ { app_health . message } " )
return 1
print ( f " ✅ { app_health . message } " )
2026-07-25 22:48:38 +02:00
# Step 8: Verify domain
print ( " \n [8/8] Verifying domain... " )
2026-07-25 22:42:05 +02:00
domain_result = verify_domain ( domain )
steps . append ( ( " Domain " , domain_result ) )
if domain_result . success :
print ( f " ✅ { domain_result . message } " )
else :
print ( f " ⚠️ { domain_result . message } (may need DNS/certificate time) " )
# Summary
print ( f " \n { ' = ' * 60 } " )
print ( " Deploy Summary " )
print ( f " { ' = ' * 60 } " )
for name , result in steps :
status = " ✅ " if result . success else " ⚠️ " if " non-fatal " in result . message else " ❌ "
print ( f " { status } { name } : { result . message } " )
# Return success if all critical steps passed
2026-07-25 22:48:38 +02:00
critical = [ s for s in steps if s [ 0 ] in ( " Coolify deploy " , " Container health " , " App health " , " Persistent volume " ) ]
2026-07-25 22:42:05 +02:00
all_critical = all ( s [ 1 ] . success for s in critical )
print ( f " \n Overall: { ' ✅ SUCCESS ' if all_critical else ' ❌ FAILED ' } \n " )
return 0 if all_critical else 1
# ─── CLI ───────────────────────────────────────────────────────────────
def main ( ) :
parser = argparse . ArgumentParser ( description = " LeoCRM automated deployment script " )
parser . add_argument ( " --environment " , " -e " , default = " production " , choices = [ " production " , " test " ] ,
help = " Target environment (default: production) " )
parser . add_argument ( " --skip-build " , action = " store_true " ,
help = " Skip Coolify build, just restart containers " )
parser . add_argument ( " --migrate-only " , action = " store_true " ,
help = " Only run migrations and RLS, no container changes " )
args = parser . parse_args ( )
sys . exit ( deploy ( environment = args . environment , skip_build = args . skip_build , migrate_only = args . migrate_only ) )
if __name__ == " __main__ " :
main ( )