refactor(deploy): remove old multi-resource code, document single docker-compose workflow

- Remove POSTGRES_COMPOSE, REDIS_COMPOSE templates (unused)
- Remove create_service(), create_api_application(), generate_worker_compose()
- Remove deploy_worker(), deploy_worker_only(), verify_worker_service()
- Remove resolve_worker_uuid() and all worker_uuid references
- Remove get_worker_envs(), get_api_envs(), get_postgres_envs(), get_redis_envs()
- Remove set_service_envs(), set_application_envs() (dead code)
- Remove _extract_deploy_uuid(), _wait_service_healthy() (only used by deploy_worker)
- Remove seed_admin_user() (only used by old deploy_full)
- Remove DB_HOST, REDIS_HOST, WORKER_UUID, WORKER_NAME config vars
- Remove --worker-only CLI arg
- Replace old deploy_full() with simple redeploy via /api/v1/deploy
- Update run_verification() to remove worker_uuid param
- Add KI workflow comment at top of deploy.py
- Update DEPLOY.md: single docker-compose stack workflow
- Update COOLIFY_SETUP.md: single docker-compose stack, remove 3-resource setup
- Update docs/INSTALL.md: automated --initial workflow

deploy.py: 1370 → 893 lines (-477 lines, -35%)
This commit is contained in:
Agent Zero
2026-08-06 01:22:09 +02:00
parent 85fcb90b32
commit 5b7d93cd0e
4 changed files with 400 additions and 877 deletions
+190 -266
View File
@@ -1,177 +1,195 @@
# Coolify Setup — CRM System v1.0
# Coolify Setup — LeoCRM
Production deployment guide for the **CRM System** to the Coolify PaaS instance
at `server.media-on.de` (server UUID `lw80w8scs4044gwcw084s00s4`).
Production deployment guide for LeoCRM to the Coolify PaaS instance
at `server.media-on.de`.
The deploy consists of **three Coolify resources** in the same project/environment:
## Architektur
1. A **PostgreSQL 16** database resource (one-click or Docker image).
2. The **crm-app** Application (Dockerfile build from a Git repository).
3. The **crm-worker** Application (same Dockerfile build, different entrypoint).
LeoCRM läuft als **einzelner docker-compose Stack** in einer Coolify Application.
Coolify liest die `docker-compose.yaml` aus dem Git-Repo und startet alle 4
Container (postgres, redis, crm_app, crm_worker) in einem gemeinsamen Stack.
The resources talk to each other over the internal Docker network. The app
is exposed publicly on `https://crm.media-on.de:443` (Let's Encrypt via Coolify).
The worker is not exposed publicly — it only needs Redis and PostgreSQL access.
```
Coolify Application (build_pack=dockercompose)
├── postgres (pgvector/pgvector:pg16)
├── redis (redis:7-alpine)
├── crm_app (FastAPI API Server, Port 8000)
└── crm_worker (ARQ Background Worker)
```
Alle Container teilen sich ein Docker-Netzwerk. Service-Namen funktionieren
als DNS-Namen (z.B. `postgres`, `redis`, `crm_app`, `crm_worker`).
**Keine separaten Coolify Services** für DB/Redis/Worker. Das funktioniert nicht,
weil Coolify jedem Service ein eigenes Netzwerk gibt und die Container sich
nicht per DNS erreichen können.
---
## 0. ⚠️ Critical domain-format gotcha
Coolify's per-application **Domain field must contain an explicit port** in the
URL. If you enter the domain without `:443`, Let's Encrypt certificate issuance
will silently fail and Traefik will not route traffic correctly.
```
✅ https://crm.media-on.de:443
❌ https://crm.media-on.de
❌ crm.media-on.de
```
> The same rule applies in the Coolify API: when calling
> `PATCH /api/v1/applications/{uuid}` you must set
> `{"domains": "https://crm.media-on.de:443"}` (note the `:443` suffix).
> This is a known bug-fix from earlier deployments — never drop the port.
---
## 1. Prerequisites
## 1. Voraussetzungen
- Coolify server reachable at `https://server.media-on.de`, API token created
in *Keys & Tokens → API tokens* (Bearer token, scope: `*`).
- The DNS **A record** for `crm.media-on.de` points to the public IP of the
Coolify server (Traefik will answer on `:443` and route by `Host` header).
- The CRM source code lives in a **Forgejo repository** that Coolify can
clone. Suggested location:
`https://forge.media-on.de/leopoldadmin/crm-system` (branch `master`).
> If the repo does not exist yet, create it and push the project:
> ```bash
> # One-time: create the repo via Forgejo API or UI
> git remote add origin https://leopoldadmin:<TOKEN>@forge.media-on.de/leopoldadmin/crm-system.git
> git push -u origin master
> ```
- You have the **internal host:port** of the Postgres resource that will be
provisioned in step 2 (Coolify will print it, e.g. `abc123-postgres:5432`).
- DNS **A record** for die App-Domain (z.B. `crm.media-on.de`) zeigt auf die
öffentliche IP des Coolify-Servers.
- LeoCRM source code in Forgejo repository:
`https://forgejo.media-on.de/Leopoldadmin/leocrm.git` (branch `main`).
- Ein **Private Deploy Key** in Coolify hinterlegt (für Git-Zugriff).
- Python 3.12+ mit `httpx` für das deploy script.
---
## 2. Resource A — PostgreSQL 16 database
## 2. Initial Deployment (automatisiert)
In the Coolify UI:
1. Go to **Databases → + Add**.
2. Choose **PostgreSQL 16** (Alpine).
3. Configuration:
- **Name**: `crm-postgres`
- **Database name**: `crm_db`
- **User**: `crm_user`
- **Password**: *(generate a strong one — see Secret generation below)*
- **Public accessibility**: **disabled** (only the crm-app talks to it)
4. Click **Deploy** and wait for status `running:healthy`.
5. Note the **internal host:port** Coolify exposes (typically
`<resource-uuid>-postgres:5432`). You will need it in step 3.
> **Alternative (API):**
> ```bash
> curl -X POST http://server.media-on.de/api/v1/databases \
> -H "Authorization: Bearer $COOLIFY_TOKEN" \
> -H "Content-Type: application/json" \
> -d '{"type":"postgresql","project_uuid":"...","environment_name":"production",
> "server_uuid":"lw80w8scs4044gwcw084s00s4",
> "name":"crm-postgres","postgres_user":"crm_user",
> "postgres_password":"<STRONG_PASSWORD>",
> "postgres_db":"crm_db","is_public":false}'
> ```
---
## 3. Resource B — crm-app (Dockerfile build)
In the Coolify UI:
1. **Projects → + Add Project** if you don't have one yet (e.g. `CRM`).
2. **Environment → + Add Environment** → name: `production`.
3. Inside that environment, **+ Add → Application → Public/Private Repository**.
4. Fill in:
- **Git repository**: `https://forge.media-on.de/leopoldadmin/crm-system`
- **Branch**: `master`
- **Build pack**: `Dockerfile`
- **Dockerfile location**: `Dockerfile` (default, repo root)
- **Port**: `8000`
5. Click **Deploy** once to let Coolify create the resource (it will fail to
start without environment variables — that's expected).
6. Note the **Application UUID** (visible in the URL or via
`GET /api/v1/applications`).
---
## 4. Environment variables (on the crm-app resource)
In **crm-app → Environment Variables**, set:
| Key | Value | Notes |
|-----|-------|-------|
| `DATABASE_URL` | `postgresql+asyncpg://crm_user:<PW>@<postgres-internal-host>:5432/crm_db` | Use the internal host from step 2 (e.g. `crm-postgres-xyz:5432`), **not** `localhost` and **not** the public DNS. |
| `AUTH_SECRET` | *see secret generation* | **MUST be ≥ 32 chars.** |
| `CORS_ORIGINS` | `https://crm.media-on.de:443` | Comma-separated, no wildcards, must match the domain where the browser actually loads the SPA. |
| `ENVIRONMENT` | `production` | |
| `LOG_LEVEL` | `INFO` | `DEBUG` only temporarily. |
| `BCRYPT_ROUNDS` | `12` | Aligned with `.env.example`. |
### Secret generation (run once, locally)
### 2.1 Umgebungsvariablen setzen
```bash
# AUTH_SECRET (min 32 chars, recommended 48+)
python -c "import secrets; print(secrets.token_urlsafe(48))"
# POSTGRES_PASSWORD (min 16 chars, recommended 24+)
python -c "import secrets; print(secrets.token_urlsafe(24))"
export COOLIFY_API_TOKEN="dein-token"
export APP_DOMAIN="https://crm.media-on.de"
export APP_NAME="leocrm"
export DB_PASSWORD="<sicheres-passwort>"
export REDIS_PASSWORD="<sicheres-passwort>"
export SECRET_KEY="<mindestens-32-zeichen>"
export COOLIFY_PROJECT_UUID="<project-uuid>"
export COOLIFY_SERVER_UUID="<server-uuid>"
export COOLIFY_PRIVATE_KEY_UUID="<private-key-uuid>"
export COOLIFY_ENVIRONMENT="production" # optional
export ADMIN_EMAIL="admin@media-on.de" # optional
export ADMIN_PASSWORD="Admin123!" # optional
```
**Never commit these values.** Coolify stores them encrypted at rest, but they
are still rendered in the UI to anyone with read access to the environment.
### 2.2 Deploy starten
> **Alternative (API — bulk update):**
> ```bash
> curl -X PATCH http://server.media-on.de/api/v1/applications/$APP_UUID/envs/bulk \
> -H "Authorization: Bearer $COOLIFY_TOKEN" \
> -H "Content-Type: application/json" \
> -d '{
> "data": [
> {"key":"DATABASE_URL", "value":"postgresql+asyncpg://crm_user:<PW>@<PG_HOST>:5432/crm_db"},
> {"key":"AUTH_SECRET", "value":"<TOKEN_URLSAFE_48>"},
> {"key":"CORS_ORIGINS", "value":"https://crm.media-on.de:443"},
> {"key":"ENVIRONMENT", "value":"production"},
> {"key":"LOG_LEVEL", "value":"INFO"},
> {"key":"BCRYPT_ROUNDS", "value":"12"}
> ]
> }'
> ```
```bash
python scripts/deploy.py --initial
```
Das Script führt einen **2-Phase Deploy** durch:
1. **Phase 1**: Application via `private-deploy-key` erstellen, build_pack auf
`dockercompose` setzen, ENV-Variablen setzen, erster Deploy **ohne Domain**.
Coolify liest `docker-compose.yaml` aus dem Git-Repo und baut alle Container.
2. **Phase 2**: `docker_compose_domains` setzen (für Traefik-Labels), dann
Redeploy. Jetzt ist die App unter der Domain erreichbar.
### 2.3 Warum 2-Phase Deploy?
Coolify muss zuerst die `docker-compose.yaml` aus dem Git-Repo lesen, um die
Service-Namen zu kennen. Erst dann kann `docker_compose_domains` korrekt
zugeordnet werden. Ein Deploy ohne vorherigen Read der Compose-Datei führt zu
fehlenden Traefik-Labels → 503 Fehler.
---
## 5. Configure the public domain (with port!)
## 3. Redeploy (bestehende Anwendung)
In **crm-app → Domains → + Add Domain**:
```bash
export COOLIFY_API_TOKEN="dein-token"
export APP_DOMAIN="https://crm.media-on.de"
export COOLIFY_APP_UUID="dx4pqdziu4uj6x9fxs1u5z0x" # optional
- **Domain**: `https://crm.media-on.de:443`
- ⚠️ **Port `:443` is mandatory.** See section 0.
- **Let's Encrypt**: **enabled** (default).
- Click **Save**. Coolify will issue the certificate and reload Traefik.
python scripts/deploy.py
```
> **Alternative (API):**
> ```bash
> curl -X PATCH http://server.media-on.de/api/v1/applications/$APP_UUID \
> -H "Authorization: Bearer $COOLIFY_TOKEN" \
> -H "Content-Type: application/json" \
> -d '{"domains": "https://crm.media-on.de:443"}'
> ```
Triggert `/api/v1/deploy` für die bestehende Coolify Application und wartet auf
Erfolg. Danach läuft automatisch die Verifikation (HTTP, Login, Alembic, RLS).
---
## 6. Healthcheck (Coolify side)
## 4. Verifikation
In **crm-app → Advanced → Healthcheck**:
```bash
python scripts/deploy.py --verify-only
```
Prüft:
- HTTP Health (`/api/v1/health`)
- Login (optional, wenn LOGIN_EMAIL/LOGIN_PASSWORD gesetzt)
- Alembic Migration Head (via SSH in den postgres Container)
- RLS-Tabellen-Anzahl (via SSH)
---
## 5. Wichtige Hinweise
### 5.1 Service-Namen mit Unterstrichen
In `docker-compose.yaml` müssen Service-Namen **Unterstriche** verwenden:
`crm_app`, `crm_worker` — nicht `crm-app`, `crm-worker`.
Coolify konvertiert Bindestriche zu Unterstrichen in `docker_compose_domains`.
Bei Bindestrichen in der Compose-Datei gibt es keinen Match → keine
Traefik-Labels → 503 Fehler.
### 5.2 docker-compose.yaml (nicht .yml)
Coolify sucht nach `docker-compose.yaml` (mit `.yaml`). Eine Datei namens
`docker-compose.yml` wird nicht gefunden.
### 5.3 Domain ohne :443
In `docker_compose_domains` darf die Domain **kein** `:443` am Ende haben:
```
✅ https://crm.media-on.de
❌ https://crm.media-on.de:443
```
Das `:443` führt zu leeren `Host()` Traefik-Labels.
### 5.4 Kein connect_to_docker_network
Innerhalb eines docker-compose Stacks kümmert sich Coolify selbst um das
Netzwerk. `connect_to_docker_network=True` ist nicht nötig und sollte nicht
gesetzt werden.
---
## 6. Mehrere Instanzen
```bash
# Test-Instanz
APP_NAME=leocrm-test APP_DOMAIN=https://crm-test.media-on.de \
python scripts/deploy.py --initial
# Produktions-Instanz
APP_NAME=leocrm APP_DOMAIN=https://crm.media-on.de \
python scripts/deploy.py --initial
```
Jede Instanz hat eigene DB, Redis, Container und Domain. Alle Parameter werden
aus `APP_NAME` und `APP_DOMAIN` abgeleitet.
---
## 7. Environment-Variablen in Coolify
Das `--initial` Script setzt automatisch folgende ENV-Variablen in Coolify:
| Key | Wert | Quelle |
|-----|------|--------|
| `POSTGRES_USER` | `crm_user` | Default |
| `POSTGRES_DB` | `crm_db` | Default |
| `DB_PASSWORD` | * | ENV |
| `REDIS_PASSWORD` | * | ENV |
| `SECRET_KEY` | * | ENV |
| `ENVIRONMENT` | `production` | Default |
| `LOG_LEVEL` | `INFO` | Default |
| `SESSION_COOKIE_SECURE` | `true` | Default |
| `STORAGE_PATH` | `/data/storage` | Default |
| `CORS_ORIGINS` | APP_DOMAIN | ENV |
| `FRONTEND_URL` | APP_DOMAIN | ENV |
| `APP_DOMAIN` | APP_DOMAIN | ENV |
| `ADMIN_EMAIL` | `admin@media-on.de` | ENV (optional) |
| `ADMIN_PASSWORD` | `Admin123!` | ENV (optional) |
Die `docker-compose.yaml` verwendet `${VARIABLE}` Syntax — Coolify substituiert
aus diesen ENV-Variablen.
---
## 8. Healthcheck
In **Coolify → Application → Advanced → Healthcheck**:
- **Healthcheck path**: `/api/v1/health`
- **Healthcheck method**: `GET`
@@ -180,138 +198,44 @@ In **crm-app → Advanced → Healthcheck**:
- **Healthcheck retries**: `3`
- **Healthcheck start period**: `15s`
> The Dockerfile's in-container `HEALTHCHECK` is the source of truth for
> Docker-level health. The Coolify/Traefik healthcheck is what drives
> automatic rollbacks and load-balancer routing. Set both, identically.
---
## 9. Going forward — Redeploys
- **Code change** → push to `main` auf Forgejo → `python scripts/deploy.py`
(oder Coolify UI → Deployments → Deploy).
- **Environment variable change** → Coolify UI (oder API `PATCH .../envs/bulk`)
→ Deploy (Coolify startet nicht automatisch bei ENV-Änderung neu).
- **Domain change** → API (`PATCH /api/v1/applications/{uuid}` mit
`docker_compose_domains`) — reproduzierbar, UI als Fallback.
---
## 7. Build & deploy
## 10. Troubleshooting
In the Coolify UI: **crm-app → Deployments → Deploy**.
**503 Fehler (Traefik):**
- Domain ohne `:443` in `docker_compose_domains`
- Service-Namen mit Unterstrichen in docker-compose.yaml
- `docker-compose.yaml` (nicht `.yml`)
Watch the build log. The first deploy will:
**Container können sich nicht erreichen (DNS):**
- Alles in einem docker-compose Stack (nicht separate Coolify Services)
- Kein `connect_to_docker_network` setzen
1. Clone the repo (branch `master`).
2. Build the multi-stage Dockerfile (≈ 12 min, depending on cache).
3. Start the container. `prestart.sh` runs `alembic upgrade head` against the
Postgres database.
4. Uvicorn binds to `0.0.0.0:8000` and starts serving.
A healthy deploy ends with the container status `running:healthy`.
> **Alternative (API):**
> ```bash
> curl -X POST http://server.media-on.de/api/v1/deploy \
> -H "Authorization: Bearer $COOLIFY_TOKEN" \
> -H "Content-Type: application/json" \
> -d "{\"uuid\":\"$APP_UUID\"}"
> ```
---
## 8. Verification
From anywhere with internet access:
**"Docker Compose file not found":**
- Datei heißt `docker-compose.yaml` (nicht `.yml`)
**Migration fehlgeschlagen:**
```bash
# 1. Root health (used by Docker HEALTHCHECK & Coolify healthcheck)
curl -fsSL -o /dev/null -w "%{http_code}\n" https://crm.media-on.de:443/health
# → 200
# 2. API v1 health (mounted under the versioned router)
curl -fsSL -o /dev/null -w "%{http_code}\n" https://crm.media-on.de:443/api/v1/health
# → 200
# 3. Frontend SPA (served by the static-files mount)
curl -fsSL -o /dev/null -w "%{http_code} %{content_type}\n" \
https://crm.media-on.de:443/index.html
# → 200 text/html
# 4. Interactive API docs
# Open in a browser: https://crm.media-on.de:443/docs
# Register a user via POST /api/v1/auth/register
# Login via POST /api/v1/auth/login → access_token
# Use the token as `Authorization: Bearer <access_token>` on protected routes
docker exec <postgres-container> psql -U crm_user -d crm_db -c "SELECT version_num FROM alembic_version"
docker exec <api-container> alembic upgrade head
```
If any of these return `502` / `503` / `504`:
- Check **crm-app → Logs** in Coolify (the UI is the only place with full
stdout/stderr, the API does not expose logs).
- Confirm the container is `running:healthy` (not `running:unhealthy`,
`exited`, or `starting`).
- Confirm the Postgres resource is `running:healthy` and the
`DATABASE_URL` host matches its internal DNS name.
For full incident response, see [`/a0/.a0/runbook-restore.md`](../../a0/runbook-restore.md).
---
## 9. Going forward — redeploys
- **Code change** → push to `master` on Forgejo → **Deployments → Deploy** in
Coolify. The Dockerfile layer-cache will reuse `pip install -r
requirements.txt` if `requirements.txt` is unchanged.
- **Environment variable change** → edit in Coolify UI (or `PATCH .../envs/bulk`
via API) → **Deploy** (Coolify does *not* auto-restart on ENV change alone).
- **Domain change** → use the API (`PATCH /api/v1/applications/{uuid}`) so it
is reproducible; the UI is a fallback only.
---
## 10. References
## 11. Referenzen
- Coolify v4 API — `/a0/usr/plugins/coolify_control/help/coolify-control/help.md`
- App architecture (Section 13 lockdown) — `/a0/.a0/02-architecture.md`
- Task graph (Phase 4d) — `/a0/.a0/03-task-graph.json`
- Restore runbook — `/a0/.a0/runbook-restore.md`
---
## 11. Resource C — crm-worker (Background Worker)
The crm-worker runs the ARQ background worker and scheduler in a separate
container, using the same Docker image as crm-app but with a different
entrypoint (`/app/worker.sh` instead of `/app/prestart.sh`).
### Setup in Coolify UI
1. In the same project/environment as crm-app, **+ Add → Application →
Public/Private Repository**.
2. Fill in:
- **Git repository**: same as crm-app (`https://forgejo.media-on.de/Leopoldadmin/leocrm.git`)
- **Branch**: `main`
- **Build pack**: `Dockerfile`
- **Dockerfile location**: `Dockerfile` (same image)
- **Port**: `8000` (not used, but Coolify requires a port)
- **Custom Entrypoint**: `/app/worker.sh`
3. Click **Deploy** once to create the resource.
4. Note the **Application UUID**.
### Environment variables (on the crm-worker resource)
Set the same variables as crm-app, except:
| Key | Value | Notes |
|-----|-------|-------|
| `DATABASE_URL` | same as crm-app | |
| `REDIS_URL` | same as crm-app | |
| `SECRET_KEY` | same as crm-app | |
| `ENVIRONMENT` | `production` | |
| `LOG_LEVEL` | `INFO` | |
| `STORAGE_PATH` | `/data/storage` | |
No domain is needed — the worker is not publicly accessible.
### Healthcheck (Coolify side)
- **Healthcheck path**: `/api/v1/health` (not used by worker, but Coolify requires one)
- Alternatively, use a custom healthcheck command:
`pgrep -f "arq app.core.worker.WorkerSettings" || exit 1`
### Scaling
To scale the worker horizontally, deploy multiple crm-worker instances.
Cron jobs use a Redis-based distributed lock (`SET NX` with TTL) so only
one replica executes each scheduled job.
- App architecture `architecture.md`
- Deploy script — `scripts/deploy.py`
- Install guide — `docs/INSTALL.md`
+108 -64
View File
@@ -1,39 +1,86 @@
# LeoCRM Deployment
## Architektur
LeoCRM läuft als **einzelner docker-compose Stack** in Coolify. Alle 4 Container
(PostgreSQL, Redis, API, Worker) werden aus der `docker-compose.yaml` im Git-Repo
gestartet und teilen sich ein Docker-Netzwerk.
```
┌─────────────────────────────────────────────┐
│ Coolify Application (docker-compose) │
│ ┌──────────┐ ┌──────────┐ │
│ │ postgres │ │ redis │ │
│ └────┬─────┘ └────┬─────┘ │
│ │ │ │
│ ┌────┴─────┐ ┌────┴─────┐ │
│ │ crm_app │ │crm_worker│ │
│ │ (API) │ │ (ARQ) │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────┘
```
Keine separaten Coolify Services für DB/Redis/Worker. Alles in einem Stack.
## Quick Start
### Option A: Coolify (empfohlen für Produktion)
### Redeploy (bestehende Anwendung)
```bash
# Einmalig: Umgebungsvariablen setzen
# Umgebungsvariablen setzen
export COOLIFY_API_TOKEN="dein-token"
export COOLIFY_APP_UUID="dx4pqdziu4uj6x9fxs1u5z0x" # oder via APP_DOMAIN auto-resolved
export APP_DOMAIN="https://crm.media-on.de"
export COOLIFY_APP_UUID="dx4pqdziu4uj6x9fxs1u5z0x" # optional, wird via APP_NAME gesucht
# Deploy
# Redeploy via Coolify API
python scripts/deploy.py
# Redeploy (ohne Neubuild)
python scripts/deploy.py --skip-build
# Verifikation nur
python scripts/deploy.py --verify-only
```
Das Script macht automatisch:
1. Coolify Build & Deploy triggern
2. Persistent Volume in Coolify DB konfigurieren (automatisch, portabel)
3. Auf healthy Container warten
4. RLS auf allen Tenant-Tabellen sicherstellen
5. DB-Migrationen verifizieren
6. Worker-Container starten
7. App-Health verifizieren
8. Domain-Erreichbarkeit prüfen
1. Coolify Application auflösen (via UUID oder Name)
2. Deploy via `/api/v1/deploy` triggern
3. Auf Deployment-Erfolg warten
4. HTTP Health-Check verifizieren
5. Login-Test (optional, wenn LOGIN_EMAIL/LOGIN_PASSWORD gesetzt)
6. Alembic-Migration-Head prüfen (via SSH)
7. RLS-Tabellen zählen (via SSH)
**Funktioniert auf jeder Coolify-Instanz. Bei mehreren Apps. Bei Erst-Deploy und Redeploy.**
### Initial Deployment (neue Anwendung)
```bash
# Alle Umgebungsvariablen setzen
export COOLIFY_API_TOKEN="dein-token"
export APP_DOMAIN="https://crm.media-on.de"
export APP_NAME="leocrm" # Coolify Application Name
export DB_PASSWORD="..."
export REDIS_PASSWORD="..."
export SECRET_KEY="..."
export COOLIFY_PROJECT_UUID="..."
export COOLIFY_SERVER_UUID="..."
export COOLIFY_PRIVATE_KEY_UUID="..."
export COOLIFY_ENVIRONMENT="production" # optional, default: production
# Initial deployment
python scripts/deploy.py --initial
```
Das Script macht automatisch:
1. Coolify Application via `private-deploy-key` erstellen
2. Build Pack auf `dockercompose` setzen (liest docker-compose.yaml aus Git)
3. Environment-Variablen setzen (Secrets, Domain, Admin-Credentials)
4. Erster Deploy (ohne Domain — Coolify muss docker-compose.yaml lesen)
5. `docker_compose_domains` setzen + Redeploy (mit Traefik-Labels)
6. Verifikation (HTTP, Login, Alembic, RLS)
### Option B: Docker Compose (lokal / ohne Coolify)
```bash
# .env.docker erstellen
cp .env.docker.example .env.docker
$EDITOR .env.docker # SECRET_KEY, POSTGRES_PASSWORD, etc. ausfüllen
$EDITOR .env.docker # SECRET_KEY, DB_PASSWORD, REDIS_PASSWORD etc. ausfüllen
# Starten (alle 4 Container: Postgres, Redis, App, Worker)
docker compose --env-file .env.docker up --build -d
@@ -46,19 +93,20 @@ docker compose down
```
**Container:**
- `crm-postgres` — PostgreSQL 16 mit pgvector
- `crm-redis` — Redis 7
- `crm-app` — FastAPI API Server
- `crm-worker` — ARQ Background Worker
- `postgres` — PostgreSQL 16 mit pgvector
- `redis` — Redis 7
- `crm_app` — FastAPI API Server
- `crm_worker` — ARQ Background Worker
Alle mit persistenten Volumes. Kein Datenverlust bei Redeploy.
## Voraussetzungen
- Python 3.12+
- `httpx` Python package
- Docker & Docker Compose (für Option B)
- Coolify v4+ (für Option A)
- SSH-Zugang zum Server (für Option A)
- SSH-Zugang zum Server (für Verifikation, Option A)
## Umgebungsvariablen
@@ -66,13 +114,26 @@ Siehe `.env.example` für alle Variablen. Wichtigste:
| Variable | Pflicht | Default | Beschreibung |
|---|---|---|---|
| `DATABASE_URL` | Ja | — | PostgreSQL Connection String |
| `REDIS_URL` | Ja | — | Redis Connection String |
| `SECRET_KEY` | Ja | — | Mindestens 32 Zeichen |
| `ENVIRONMENT` | Nein | `development` | `production` oder `development` |
| `SESSION_COOKIE_SECURE` | Nein | `true` | In Production muss `true` |
| `STORAGE_PATH` | Nein | `/data/storage` | Datei-Upload-Pfad |
| `STORAGE_BACKEND` | Nein | `local` | `local` oder `s3` |
| `COOLIFY_API_TOKEN` | Ja | — | Coolify API Token |
| `APP_DOMAIN` | Ja | — | App-Domain (z.B. https://crm.media-on.de) |
| `COOLIFY_APP_UUID` | Nein | — | Application UUID (auto-resolved via APP_NAME) |
| `APP_NAME` | Nein | abgeleitet aus APP_DOMAIN | Coolify Application Name |
| `SSH_KEY` | Nein | `/a0/usr/workdir/.ssh/coolify-01-root` | SSH Key für Verifikation |
| `SERVER_IP` | Nein | `46.225.91.159` | Server IP für SSH |
| `LOGIN_EMAIL` | Nein | — | Login-Test Email (optional) |
| `LOGIN_PASSWORD` | Nein | — | Login-Test Passwort (optional) |
### Nur für `--initial`:
| Variable | Pflicht | Beschreibung |
|---|---|---|
| `DB_PASSWORD` | Ja | PostgreSQL Passwort |
| `REDIS_PASSWORD` | Ja | Redis Passwort |
| `SECRET_KEY` | Ja | Application Secret Key (min. 32 Zeichen) |
| `COOLIFY_PROJECT_UUID` | Ja | Coolify Project UUID |
| `COOLIFY_SERVER_UUID` | Ja | Coolify Server UUID |
| `COOLIFY_PRIVATE_KEY_UUID` | Ja | Coolify Private Deploy Key UUID |
| `COOLIFY_ENVIRONMENT` | Nein | Coolify Environment (default: production) |
## S3 Storage (optional)
@@ -85,19 +146,19 @@ S3_ACCESS_KEY=...
S3_SECRET_KEY=...
```
## Test- vs. Produktionsumgebung
## Mehrere Instanzen
**Test:**
Mehrere LeoCRM-Instanzen auf demselben Coolify-Server:
```bash
python scripts/deploy.py --environment test
```
Eigene Coolify-App, eigene DB, eigene Domain (`crm-test.media-on.de`).
# Test-Instanz
APP_NAME=leocrm-test APP_DOMAIN=https://crm-test.media-on.de python scripts/deploy.py --initial
**Produktion:**
```bash
python scripts/deploy.py --environment production
# Produktions-Instanz
APP_NAME=leocrm APP_DOMAIN=https://crm.media-on.de python scripts/deploy.py --initial
```
Jede Instanz hat eigene DB, Redis, Container und Domain.
## Troubleshooting
**Container nicht healthy:**
@@ -110,15 +171,10 @@ docker logs <container-name> --tail 50
docker exec <container> alembic upgrade head
```
**RLS nicht aktiv:**
```bash
python scripts/deploy.py --migrate-only
```
**Worker nicht gestartet:**
```bash
python scripts/deploy.py --skip-build # startet Worker automatisch
```
**503 Fehler (Traefik):**
- Domain ohne `:443` in `docker_compose_domains` setzen
- Service-Namen mit Unterstrichen in docker-compose.yaml (crm_app, nicht crm-app)
- `docker-compose.yaml` (nicht `.yml`) als Dateiname
## Backup & Restore
@@ -126,46 +182,34 @@ python scripts/deploy.py --skip-build # startet Worker automatisch
```bash
# Full DB backup (run on the host or via docker exec)
docker exec crm-postgres pg_dump -U crm_user -Fc crm_db > backup_$(date +%Y%m%d_%H%M%S).dump
# Backup mit Custom-Format (komprimiert, parallel restore-fähig)
docker exec crm-postgres pg_dump -U crm_user -Fc -Z 9 crm_db > backup_$(date +%Y%m%d).dump
docker exec <postgres-container> pg_dump -U crm_user -Fc crm_db > backup_$(date +%Y%m%d_%H%M%S).dump
```
### Backup (Redis — Sessions/Queues)
```bash
# Redis RDB Snapshot
docker exec crm-redis redis-cli -a "$REDIS_PASSWORD" SAVE
docker cp crm-redis:/data/dump.rdb redis_backup_$(date +%Y%m%d).rdb
```
### Backup (File Storage)
```bash
# Local storage volume
docker run --rm -v leocrm-fix_storage:/data -v $(pwd):/backup alpine \
tar czf /backup/storage_$(date +%Y%m%d).tar.gz /data
docker exec <redis-container> redis-cli -a "$REDIS_PASSWORD" SAVE
docker cp <redis-container>:/data/dump.rdb redis_backup_$(date +%Y%m%d).rdb
```
### Restore (PostgreSQL)
```bash
# Stop app containers
docker compose stop crm-app crm-worker
docker compose stop crm_app crm_worker
# Restore DB
docker exec -i crm-postgres pg_restore -U crm_user -d crm_db --clean < backup_20260726.dump
docker exec -i <postgres-container> pg_restore -U crm_user -d crm_db --clean < backup_20260726.dump
# Restart app
docker compose start crm-app crm-worker
docker compose start crm_app crm_worker
```
### Automatisierte Backups (Cron)
```bash
# /etc/cron.d/leocrm-backup
0 2 * * * root docker exec crm-postgres pg_dump -U crm_user -Fc crm_db > /backups/leocrm_$(date +\%Y\%m\%d).dump
0 2 * * * root docker exec <postgres-container> pg_dump -U crm_user -Fc crm_db > /backups/leocrm_$(date +\%Y\%m\%d).dump
0 3 * * * root find /backups -name 'leocrm_*.dump' -mtime +30 -delete
```
+46 -14
View File
@@ -241,28 +241,60 @@ SECRET_KEY=your-secret-key-with-at-least-32-characters!!
---
## 6. Coolify-Setup
### 6.1 Neue Anwendung erstellen (automatisiert)
### 6.1 Neue Anwendung erstellen
LeoCRM wird als einzelner docker-compose Stack in Coolify deployt. Alle 4 Container
(postgres, redis, crm_app, crm_worker) laufen in einer Coolify Application mit
`build_pack=dockercompose`.
1. In Coolify: **+ New Resource** → **Docker Compose**
2. Name: `leocrm`
3. Compose-Datei einfügen (siehe oben)
4. Domain zuweisen: `crm.example.com`
```bash
# Umgebungsvariablen setzen
export COOLIFY_API_TOKEN="dein-token"
export APP_DOMAIN="https://crm.example.com"
export APP_NAME="leocrm"
export DB_PASSWORD="YourSecurePassword2026"
export REDIS_PASSWORD="YourRedisPassword2026"
export SECRET_KEY="your-secret-key-with-at-least-32-characters!!"
export COOLIFY_PROJECT_UUID="<project-uuid>"
export COOLIFY_SERVER_UUID="<server-uuid>"
export COOLIFY_PRIVATE_KEY_UUID="<private-key-uuid>"
### 6.2 Environment-Variablen in Coolify
# Initial deployment (2-Phase: ohne Domain, dann mit Domain + Redeploy)
python scripts/deploy.py --initial
```
Alle Variablen aus der `.env`-Datei in Coolify als Environment-Variablen setzen.
Das Script macht automatisch:
1. Coolify Application via `private-deploy-key` erstellen
2. Build Pack auf `dockercompose` setzen (liest docker-compose.yaml aus Git)
3. Environment-Variablen setzen (Secrets, Domain, Admin-Credentials)
4. Erster Deploy (ohne Domain — Coolify muss docker-compose.yaml lesen)
5. `docker_compose_domains` setzen + Redeploy (mit Traefik-Labels)
6. Verifikation (HTTP, Login, Alembic, RLS)
### 6.3 Deploy
### 6.2 Redeploy (bestehende Anwendung)
1. **Deploy** klicken
2. Warten bis API-Container healthy wird (start_period: 180s)
3. Worker-Container wird automatisch healthy
```bash
export COOLIFY_API_TOKEN="dein-token"
export APP_DOMAIN="https://crm.example.com"
python scripts/deploy.py
```
### 6.4 WICHTIG: DB-Image
### 6.3 Wichtige Hinweise
- **docker-compose.yaml** (nicht `.yml`) — Coolify sucht nach `.yaml`
- **Service-Namen mit Unterstrichen**: `crm_app`, `crm_worker` (nicht mit Bindestrich)
- **Domain ohne `:443`** in `docker_compose_domains``:443` führt zu leeren Traefik-Labels
- **Kein `connect_to_docker_network`** — Coolify kümmert sich selbst um das Netzwerk im Stack
- **DB-Image**: MUSS `pgvector/pgvector:pg16` sein (nicht `postgres:16-alpine`)
### 6.4 Environment-Variablen in Coolify
Das `--initial` Script setzt automatisch alle benötigten ENV-Variablen in Coolify.
Die `docker-compose.yaml` verwendet `${VARIABLE}` Syntax — Coolify substituiert
aus diesen ENV-Variablen.
Siehe auch: `DEPLOY.md` und `COOLIFY_SETUP.md` für Details.
Das DB-Image MUSS `pgvector/pgvector:pg16` sein, nicht `postgres:16-alpine`.
LeoCRM benötigt die `vector`-Extension für die unified_search-Plugin-Migration.
---
+56 -533
View File
@@ -1,37 +1,54 @@
#!/usr/bin/env python3
"""Automated deployment script for LeoCRM via Coolify API.
All container management is done through the Coolify API — no manual
docker commands, no SSH for container lifecycle. SSH is used *only*
for post-deploy verification (Alembic version, RLS table count) because
the Coolify API does not expose database internals.
═══════════════════════════════════════════════════════════════════════
CURRENT WORKFLOW — Single docker-compose Stack
═══════════════════════════════════════════════════════════════════════
No UUIDs, domains, or secrets are hardcoded. Everything comes from
environment variables or is resolved via the Coolify API.
All 4 containers (postgres, redis, crm_app, crm_worker) run in a single
docker-compose stack managed by one Coolify Application.
deploy.py --initial → Create the docker-compose application from scratch
(private-deploy-key + PATCH to dockercompose).
Two-phase: first deploy without domain, then set
docker_compose_domains and redeploy.
deploy.py → Redeploy existing application via /api/v1/deploy.
deploy.py --verify-only → Run verification checks only.
No separate Coolify services for worker/db/redis. Everything is in one
stack so containers share a network and can reach each other by DNS.
═══════════════════════════════════════════════════════════════════════
Usage:
python scripts/deploy.py # Full deploy (API + Worker)
python scripts/deploy.py # Redeploy via Coolify API
python scripts/deploy.py --skip-build # Skip build, just restart
python scripts/deploy.py --worker-only # Only deploy worker service
python scripts/deploy.py --verify-only # Only run verification
python scripts/deploy.py --initial # Create all resources from scratch
python scripts/deploy.py --verify-only # Only run verification
Environment variables:
COOLIFY_API_TOKEN — Coolify API token (required)
COOLIFY_BASE_URL — Coolify base URL (default: https://server.media-on.de)
COOLIFY_APP_UUID — Application UUID (optional, resolved via API if absent)
COOLIFY_WORKER_UUID — Worker Service UUID (optional, resolved via API if absent)
APP_NAME — Application name for API lookup (default: leocrm-api)
WORKER_NAME — Worker name for API lookup (default: leocrm-worker)
APP_NAME — Application name for API lookup (default: derived from APP_DOMAIN)
APP_DOMAIN — App domain for health/FQDN (required, e.g. https://crm.media-on.de)
SSH_KEY — SSH key path for verification (default: /a0/usr/workdir/.ssh/coolify-01-root)
SERVER_IP — Server IP for SSH verification (default: 46.225.91.159)
SSH_KEY — SSH key path for verification (default: /a0/usr/workdir/.ssh/coolify-01-root)
SERVER_IP — Server IP for SSH verification (default: 46.225.91.159)
LOGIN_EMAIL — Login test email (optional, for verification)
LOGIN_PASSWORD — Login test password (optional, for verification)
Secrets (required for --initial, used by regular deploy if setting ENVs):
Secrets (required for --initial):
DB_PASSWORD — PostgreSQL password for all roles
REDIS_PASSWORD — Redis password
SECRET_KEY — Application secret key
Initial deploy only:
COOLIFY_PROJECT_UUID — Coolify project UUID
COOLIFY_SERVER_UUID — Coolify server UUID
COOLIFY_PRIVATE_KEY_UUID — Coolify private deploy key UUID
COOLIFY_ENVIRONMENT — Coolify environment name (default: production)
Exit codes:
0 — deployment successful
1 — deployment failed
@@ -56,12 +73,10 @@ import httpx
COOLIFY_BASE_URL = os.environ.get("COOLIFY_BASE_URL", "https://server.media-on.de")
COOLIFY_TOKEN = os.environ.get("COOLIFY_API_TOKEN", "")
# UUIDs — from env or resolved via API lookup by name
# UUID — from env or resolved via API lookup by name
APP_UUID = os.environ.get("COOLIFY_APP_UUID", "")
WORKER_UUID = os.environ.get("COOLIFY_WORKER_UUID", "")
# Names for API lookup when UUIDs are not provided
# Derive from APP_DOMAIN if not set (e.g. https://crm.media-on.de → crm)
# Derive APP_NAME from APP_DOMAIN if not set (e.g. https://crm.media-on.de → crm)
_domain_default = ""
if os.environ.get("APP_DOMAIN"):
try:
@@ -69,7 +84,6 @@ if os.environ.get("APP_DOMAIN"):
except (IndexError, ValueError):
pass
APP_NAME = os.environ.get("APP_NAME", _domain_default or "app")
WORKER_NAME = os.environ.get("WORKER_NAME", f"{APP_NAME}-worker")
# Domain (required)
APP_DOMAIN = os.environ.get("APP_DOMAIN", "")
@@ -87,10 +101,8 @@ DB_PASSWORD = os.environ.get("DB_PASSWORD", "")
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")
SECRET_KEY = os.environ.get("SECRET_KEY", "")
# Database/Redis host names (defaults match Coolify service names)
DB_HOST = os.environ.get("DB_HOST", "crm-postgres")
# Database name (used by deploy_initial for POSTGRES_DB env)
DB_NAME = os.environ.get("DB_NAME", "crm_db")
REDIS_HOST = os.environ.get("REDIS_HOST", "crm-redis")
# Git repo for initial deployment
API_GIT_REPO = os.environ.get("API_GIT_REPO", "https://forgejo.media-on.de/Leopoldadmin/leocrm.git")
@@ -165,7 +177,7 @@ class CoolifyClient:
resp.raise_for_status()
return resp.json()
# ── Services (Worker) ──
# ── Services ──
def list_services(self) -> list[dict[str, Any]]:
"""List all services."""
@@ -273,114 +285,6 @@ def resolve_app_uuid(client: CoolifyClient) -> str:
raise ValueError(f"Application '{APP_NAME}' not found in Coolify")
def resolve_worker_uuid(client: CoolifyClient) -> str | None:
"""Resolve worker UUID from env var or API lookup by name.
Returns None if worker is not configured (non-fatal)."""
if WORKER_UUID:
return WORKER_UUID
print(f" COOLIFY_WORKER_UUID not set — looking up '{WORKER_NAME}' via Coolify API...")
try:
services = client.list_services()
for svc in services:
name = svc.get("name", "")
uuid = svc.get("uuid", "")
if name == WORKER_NAME:
print(f" Found: {name}{uuid}")
return uuid
print(f" Worker '{WORKER_NAME}' not found — worker deploy will be skipped")
return None
except Exception as e:
print(f" Warning: could not list services: {e}")
return None
# ─── Worker Compose Generation ────────────────────────────────────────
def generate_worker_compose(app_uuid: str, worker_uuid: str) -> str:
"""Generate worker docker-compose YAML dynamically (no hardcoded UUIDs).
Uses ${VARIABLE} syntax for secrets — Coolify substitutes from ENV.
"""
return (
"services:\n"
" worker:\n"
f" image: '{app_uuid}:latest'\n"
" restart: unless-stopped\n"
" entrypoint:\n"
" - /app/worker.sh\n"
" environment:\n"
f" DATABASE_URL: 'postgresql+asyncpg://crm_worker:${{DB_PASSWORD}}@{DB_HOST}:5432/{DB_NAME}'\n"
f" WORKER_DATABASE_URL: 'postgresql+asyncpg://crm_worker:${{DB_PASSWORD}}@{DB_HOST}:5432/{DB_NAME}'\n"
f" MIGRATION_DATABASE_URL: 'postgresql+asyncpg://crm_user:${{DB_PASSWORD}}@{DB_HOST}:5432/{DB_NAME}'\n"
f" AUTH_DATABASE_URL: 'postgresql+asyncpg://crm_auth:${{DB_PASSWORD}}@{DB_HOST}:5432/{DB_NAME}'\n"
f" REDIS_URL: 'redis://default:${{REDIS_PASSWORD}}@{REDIS_HOST}:6379/0'\n"
" SECRET_KEY: ${SECRET_KEY}\n"
" ENVIRONMENT: ${ENVIRONMENT}\n"
" STORAGE_PATH: ${STORAGE_PATH}\n"
f" COOLIFY_RESOURCE_UUID: {worker_uuid}\n"
f" COOLIFY_CONTAINER_NAME: worker-{worker_uuid}\n"
" SERVICE_NAME_WORKER: worker\n"
" volumes:\n"
f" - '{worker_uuid}_leocrm-worker-storage:/data/storage'\n"
" networks:\n"
" - coolify\n"
f" - {worker_uuid}\n"
f" container_name: worker-{worker_uuid}\n"
" labels:\n"
" - coolify.managed=true\n"
" - coolify.version=4.0.0-beta.470\n"
" - coolify.type=service\n"
f" - coolify.name=worker-{worker_uuid}\n"
f" - coolify.resourceName={WORKER_NAME}\n"
" - coolify.serviceName=worker\n"
" - coolify.service.subType=application\n"
" - coolify.service.subName=worker\n"
"volumes:\n"
" leocrm-worker-storage:\n"
" name: leocrm-worker-storage\n"
f" {worker_uuid}_leocrm-worker-storage:\n"
f" name: {worker_uuid}_leocrm-worker-storage\n"
"networks:\n"
" coolify:\n"
" external: true\n"
" name: coolify\n"
f" {worker_uuid}:\n"
f" name: {worker_uuid}\n"
" external: true\n"
)
def get_worker_envs() -> list[dict]:
"""Worker ENV variables from environment (no hardcoded secrets)."""
return [
{"key": "DB_PASSWORD", "value": DB_PASSWORD},
{"key": "REDIS_PASSWORD", "value": REDIS_PASSWORD},
{"key": "SECRET_KEY", "value": SECRET_KEY},
{"key": "ENVIRONMENT", "value": os.environ.get("ENVIRONMENT", "production")},
{"key": "STORAGE_PATH", "value": os.environ.get("STORAGE_PATH", "/data/storage")},
]
def get_api_envs() -> list[dict]:
"""API ENV variables from environment (no hardcoded secrets)."""
return [
{"key": "DATABASE_URL", "value": f"postgresql+asyncpg://crm_api:{DB_PASSWORD}@{DB_HOST}:5432/{DB_NAME}"},
{"key": "AUTH_DATABASE_URL", "value": f"postgresql+asyncpg://crm_auth:{DB_PASSWORD}@{DB_HOST}:5432/{DB_NAME}"},
{"key": "WORKER_DATABASE_URL", "value": f"postgresql+asyncpg://crm_worker:{DB_PASSWORD}@{DB_HOST}:5432/{DB_NAME}"},
{"key": "MIGRATION_DATABASE_URL", "value": f"postgresql+asyncpg://crm_user:{DB_PASSWORD}@{DB_HOST}:5432/{DB_NAME}"},
{"key": "REDIS_URL", "value": f"redis://default:{REDIS_PASSWORD}@{REDIS_HOST}:6379/0"},
{"key": "SECRET_KEY", "value": SECRET_KEY},
{"key": "ENVIRONMENT", "value": os.environ.get("ENVIRONMENT", "production")},
{"key": "STORAGE_PATH", "value": os.environ.get("STORAGE_PATH", "/data/storage")},
{"key": "FRONTEND_URL", "value": APP_DOMAIN},
{"key": "CORS_ORIGINS", "value": APP_DOMAIN},
{"key": "SESSION_COOKIE_SECURE", "value": "true"},
{"key": "LOG_LEVEL", "value": os.environ.get("LOG_LEVEL", "INFO")},
]
# ─── SSH Helper (verification only) ────────────────────────────────────
@@ -433,93 +337,6 @@ def deploy_api(client: CoolifyClient, app_uuid: str, skip_build: bool = False) -
return StepResult(False, f"Deploy trigger failed: {e}")
def deploy_worker(client: CoolifyClient, app_uuid: str, worker_uuid: str, skip_build: bool = False) -> StepResult:
"""Deploy the worker service via Coolify API.
Steps:
1. Update service compose (dynamically generated, ${VARIABLE} syntax)
2. Set connect_to_docker_network=True (coolify network for Redis/Postgres)
3. Set ENV variables via Coolify API (secrets from environment)
4. Tag latest API image as :latest (Coolify uses commit-hash tags)
5. Deploy via POST /deploy (creates new container)
6. Wait for healthy
"""
print(" Deploying worker service via Coolify API...")
try:
# Step 1: Update service compose with dynamic UUIDs and ${VARIABLE} syntax
compose_yaml = generate_worker_compose(app_uuid, worker_uuid)
print(" Updating worker service compose...")
client.update_service(worker_uuid, compose_yaml)
time.sleep(2)
# Step 2: Set connect_to_docker_network=True
print(" Ensuring coolify network connection...")
resp = httpx.patch(
f"{client.base_url}/api/v1/services/{worker_uuid}",
headers=client.headers,
json={"connect_to_docker_network": True},
timeout=30,
)
if resp.status_code != 200:
print(f" Warning: could not set connect_to_docker_network ({resp.status_code})")
# Step 3: Set ENV variables via Coolify API (Coolify auto-generates .env)
print(" Setting ENV variables via Coolify API...")
for env in get_worker_envs():
resp = httpx.post(
f"{client.base_url}/api/v1/services/{worker_uuid}/envs",
headers=client.headers,
json=env,
timeout=30,
)
if resp.status_code == 409:
resp = httpx.patch(
f"{client.base_url}/api/v1/services/{worker_uuid}/envs",
headers=client.headers,
json=env,
timeout=30,
)
if resp.status_code not in (200, 201):
print(f" Warning: could not set ENV {env['key']} ({resp.status_code})")
# Step 4: Tag the latest API image as :latest
print(" Tagging latest API image as :latest...")
tag_code, tag_output = ssh_run(
f'docker images --format "{{{{.Repository}}}}:{{{{.Tag}}}}" | '
f'grep "^{app_uuid}:" | grep -v latest | head -1 | '
f'xargs -I{{}} docker tag {{}} {app_uuid}:latest'
)
if tag_code != 0:
print(f" Warning: could not tag :latest ({tag_output.strip()})")
# Step 5: Deploy via POST /deploy
print(" Deploying worker service...")
result = client.deploy_application(worker_uuid)
deploy_uuid = _extract_deploy_uuid(result)
if deploy_uuid:
print(f" Worker deploy queued: {deploy_uuid[:12]}")
dep_result = _wait_deployment(client, deploy_uuid, timeout=120)
if not dep_result.success:
return dep_result
else:
print(" No deployment UUID returned, waiting for healthy...")
# Step 6: Wait for healthy
return _wait_service_healthy(client, worker_uuid, timeout=120)
except Exception as e:
return StepResult(False, f"Worker deploy failed: {e}")
def _extract_deploy_uuid(result: dict[str, Any]) -> str | None:
"""Extract deployment UUID from Coolify deploy response."""
deployments = result.get("deployments", [])
if deployments and isinstance(deployments, list):
return deployments[0].get("deployment_uuid")
return result.get("deployment_uuid")
def _wait_deployment(client: CoolifyClient, deploy_uuid: str, timeout: int = 300) -> StepResult:
"""Wait for a Coolify deployment to reach success/failed status."""
print(f" Waiting for deployment {deploy_uuid[:12]}...")
@@ -540,26 +357,6 @@ def _wait_deployment(client: CoolifyClient, deploy_uuid: str, timeout: int = 300
return StepResult(False, f"Deployment timed out after {timeout}s", time.time() - start)
def _wait_service_healthy(client: CoolifyClient, service_uuid: str, timeout: int = 120) -> StepResult:
"""Wait for a Coolify service to reach running:healthy status."""
print(f" Waiting for service {service_uuid[:12]} to become healthy...")
start = time.time()
while time.time() - start < timeout:
try:
svc = client.get_service(service_uuid)
status = svc.get("status", "unknown")
elapsed = int(time.time() - start)
print(f" [{elapsed}s] Service status: {status}")
if status == "running:healthy" or status == "healthy":
return StepResult(True, f"Service healthy: {status}", time.time() - start, svc)
if "failed" in status.lower() or "error" in status.lower():
return StepResult(False, f"Service failed: {status}", time.time() - start, svc)
except Exception as e:
print(f" Warning: API error: {e}")
time.sleep(5)
return StepResult(False, f"Service did not become healthy in {timeout}s", time.time() - start)
# ─── Verification ──────────────────────────────────────────────────────
@@ -665,21 +462,7 @@ def verify_rls() -> StepResult:
)
def verify_worker_service(client: CoolifyClient, worker_uuid: str) -> StepResult:
"""Verify worker service is running via Coolify API."""
print(" Verifying worker service via Coolify API...")
try:
svc = client.get_service(worker_uuid)
status = svc.get("status", "unknown")
status_lower = status.lower()
if "running" in status_lower or "healthy" in status_lower or status_lower == "up":
return StepResult(True, f"Worker service: {status}", details={"status": status})
return StepResult(False, f"Worker service not running: {status}", details={"status": status})
except Exception as e:
return StepResult(False, f"Worker service check failed: {e}")
def run_verification(client: CoolifyClient, worker_uuid: str | None = None) -> list[tuple[str, StepResult]]:
def run_verification(client: CoolifyClient) -> list[tuple[str, StepResult]]:
"""Run all verification checks and return results."""
results: list[tuple[str, StepResult]] = []
@@ -703,12 +486,6 @@ def run_verification(client: CoolifyClient, worker_uuid: str | None = None) -> l
results.append(("RLS tables", r))
_print_result(r)
if worker_uuid:
print("\n[Verify] Worker service status...")
r = verify_worker_service(client, worker_uuid)
results.append(("Worker service", r))
_print_result(r)
return results
@@ -751,29 +528,14 @@ def validate_config() -> list[str]:
# ─── Main Deploy Pipeline ──────────────────────────────────────────────
def seed_admin_user(app_uuid: str) -> StepResult:
"""Seed admin user after fresh deployment via SSH into the app container."""
admin_email = os.environ.get("ADMIN_EMAIL", "admin@media-on.de")
admin_password = os.environ.get("ADMIN_PASSWORD", "Admin123!")
if not admin_password:
return StepResult(True, "Skipped — ADMIN_PASSWORD not set")
print(f"\n[Seed] Seeding admin user ({admin_email})...")
cmd = f"docker ps --filter name={app_uuid} --format '{{{{.Names}}}}' | grep crm-app | head -1"
rc, container = ssh_run(cmd, timeout=30)
if rc != 0 or not container.strip():
return StepResult(False, f"Could not find crm-app container for {app_uuid}")
container = container.strip()
seed_cmd = f"docker exec {container} python3 /app/scripts/seed_admin.py"
rc, out = ssh_run(seed_cmd, timeout=60)
if rc != 0:
return StepResult(False, f"Admin seed failed: {out.strip()}")
return StepResult(True, f"Admin user seeded: {admin_email}")
def deploy_full(skip_build: bool = False) -> int:
"""Full deploy: API + Worker + Verification."""
"""Redeploy the application via Coolify API (/api/v1/deploy).
This is the standard redeploy workflow for an existing docker-compose stack.
For initial creation, use deploy_initial() via --initial.
"""
print(f"\n{'='*60}")
print(" LeoCRM Full Deploy")
print(" LeoCRM Deploy")
print(f"{'='*60}\n")
errors = validate_config()
@@ -785,8 +547,8 @@ def deploy_full(skip_build: bool = False) -> int:
client = CoolifyClient(COOLIFY_BASE_URL, COOLIFY_TOKEN)
steps: list[tuple[str, StepResult]] = []
# Resolve UUIDs
print("\n[0/3] Resolving Coolify resources...")
# Step 1: Resolve UUID
print("\n[1/2] Resolving Coolify application...")
try:
app_uuid = resolve_app_uuid(client)
print(f" App UUID: {app_uuid}")
@@ -794,12 +556,8 @@ def deploy_full(skip_build: bool = False) -> int:
print(f"ERROR: {e}")
return 2
worker_uuid = resolve_worker_uuid(client)
if worker_uuid:
print(f" Worker UUID: {worker_uuid}")
# Step 1: Deploy API
print("\n[1/3] Deploying API via Coolify API...")
# Step 2: Deploy via Coolify API
print("\n[2/2] Deploying via Coolify API...")
r = deploy_api(client, app_uuid, skip_build=skip_build)
steps.append(("API deploy", r))
_print_result(r)
@@ -807,71 +565,9 @@ def deploy_full(skip_build: bool = False) -> int:
print_summary(steps)
return 1
# Step 2: Deploy Worker
if worker_uuid:
print("\n[2/3] Deploying Worker via Coolify API...")
r = deploy_worker(client, app_uuid, worker_uuid, skip_build=skip_build)
steps.append(("Worker deploy", r))
_print_result(r)
else:
print("\n[2/3] Worker deploy skipped (no worker UUID resolved)")
steps.append(("Worker deploy", StepResult(True, "Skipped — no worker configured")))
# Step 3: Seed admin user (if ADMIN_PASSWORD set)
print("\n[3/4] Seeding admin user...")
r = seed_admin_user(app_uuid)
steps.append(("Admin seed", r))
_print_result(r)
# Step 4: Verification
print("\n[4/4] Running verification...")
verify_results = run_verification(client, worker_uuid=worker_uuid)
steps.extend(verify_results)
all_ok = print_summary(steps)
return 0 if all_ok else 1
def deploy_worker_only(skip_build: bool = False) -> int:
"""Deploy only the worker service + verification."""
print(f"\n{'='*60}")
print(" LeoCRM Worker-Only Deploy")
print(f"{'='*60}\n")
errors = validate_config()
if errors:
for e in errors:
print(f"ERROR: {e}")
return 2
client = CoolifyClient(COOLIFY_BASE_URL, COOLIFY_TOKEN)
steps: list[tuple[str, StepResult]] = []
# Resolve UUIDs
print("\n[0/2] Resolving Coolify resources...")
try:
app_uuid = resolve_app_uuid(client)
except Exception as e:
print(f"ERROR: {e}")
return 2
worker_uuid = resolve_worker_uuid(client)
if not worker_uuid:
print("ERROR: No worker UUID resolved (COOLIFY_WORKER_UUID not set and API lookup failed)")
return 2
# Step 1: Deploy Worker
print("\n[1/2] Deploying Worker via Coolify API...")
r = deploy_worker(client, app_uuid, worker_uuid, skip_build=skip_build)
steps.append(("Worker deploy", r))
_print_result(r)
if not r.success:
print_summary(steps)
return 1
# Step 2: Verification
print("\n[2/2] Running verification...")
verify_results = run_verification(client, worker_uuid=worker_uuid)
# Step 3: Verification
print("\n[3/3] Running verification...")
verify_results = run_verification(client)
steps.extend(verify_results)
all_ok = print_summary(steps)
@@ -891,182 +587,16 @@ def verify_only() -> int:
return 2
client = CoolifyClient(COOLIFY_BASE_URL, COOLIFY_TOKEN)
worker_uuid = resolve_worker_uuid(client)
results = run_verification(client, worker_uuid=worker_uuid)
results = run_verification(client)
all_ok = print_summary(results)
return 0 if all_ok else 1
# ─── Initial Deployment (create all Coolify resources from scratch) ──────
# PostgreSQL Compose (pgvector for embeddings)
POSTGRES_COMPOSE = (
"services:\n"
" postgres:\n"
" image: pgvector/pgvector:pg16\n"
" container_name: crm-postgres\n"
" restart: unless-stopped\n"
" environment:\n"
" POSTGRES_USER: ${DB_USER}\n"
" POSTGRES_PASSWORD: ${DB_PASSWORD}\n"
" POSTGRES_DB: ${DB_NAME}\n"
" PGDATA: /var/lib/postgresql/data/pgdata\n"
" volumes:\n"
" - pgdata:/var/lib/postgresql/data\n"
" healthcheck:\n"
" test: ['CMD-SHELL', 'pg_isready -U ${DB_USER} -d ${DB_NAME}']\n"
" interval: 10s\n"
" timeout: 5s\n"
" retries: 5\n"
" start_period: 10s\n"
"volumes:\n"
" pgdata:\n"
)
# Redis Compose
REDIS_COMPOSE = (
"services:\n"
" redis:\n"
" image: redis:7-alpine\n"
" container_name: crm-redis\n"
" restart: unless-stopped\n"
" command: redis-server --requirepass ${REDIS_PASSWORD}\n"
" volumes:\n"
" - redisdata:/data\n"
" healthcheck:\n"
" test: ['CMD-SHELL', 'redis-cli ping || exit 1']\n"
" interval: 10s\n"
" timeout: 5s\n"
" retries: 5\n"
"volumes:\n"
" redisdata:\n"
)
def get_postgres_envs() -> list[dict]:
"""PostgreSQL ENV variables from environment."""
return [
{"key": "DB_USER", "value": os.environ.get("DB_USER", "crm_user")},
{"key": "DB_PASSWORD", "value": DB_PASSWORD},
{"key": "DB_NAME", "value": DB_NAME},
]
def get_redis_envs() -> list[dict]:
"""Redis ENV variables from environment."""
return [
{"key": "REDIS_PASSWORD", "value": REDIS_PASSWORD},
]
def create_service(client: CoolifyClient, project_uuid: str, environment_name: str,
server_uuid: str, compose_raw: str, name: str) -> str | None:
"""Create a Coolify service from a docker-compose definition.
Returns the service UUID or None on failure."""
encoded = base64.b64encode(compose_raw.encode()).decode()
try:
resp = httpx.post(
f"{client.base_url}/api/v1/services",
headers=client.headers,
json={
"project_uuid": project_uuid,
"environment_name": environment_name,
"server_uuid": server_uuid,
"docker_compose_raw": encoded,
"name": name,
},
timeout=30,
)
if resp.status_code in (200, 201):
data = resp.json()
return data.get("uuid")
print(f" Error creating service {name}: {resp.status_code} {resp.text[:200]}")
return None
except Exception as e:
print(f" Error creating service {name}: {e}")
return None
def set_service_envs(client: CoolifyClient, service_uuid: str, envs: list[dict]) -> None:
"""Set ENV variables for a service via Coolify API."""
for env in envs:
resp = httpx.post(
f"{client.base_url}/api/v1/services/{service_uuid}/envs",
headers=client.headers,
json=env,
timeout=30,
)
if resp.status_code == 409:
resp = httpx.patch(
f"{client.base_url}/api/v1/services/{service_uuid}/envs",
headers=client.headers,
json=env,
timeout=30,
)
if resp.status_code not in (200, 201):
print(f" Warning: could not set ENV {env['key']} ({resp.status_code})")
def set_application_envs(client: CoolifyClient, app_uuid: str, envs: list[dict]) -> None:
"""Set ENV variables for an application via Coolify API."""
for env in envs:
resp = httpx.post(
f"{client.base_url}/api/v1/applications/{app_uuid}/envs",
headers=client.headers,
json=env,
timeout=30,
)
if resp.status_code == 409:
resp = httpx.patch(
f"{client.base_url}/api/v1/applications/{app_uuid}/envs",
headers=client.headers,
json=env,
timeout=30,
)
if resp.status_code not in (200, 201):
print(f" Warning: could not set ENV {env['key']} ({resp.status_code})")
def create_api_application(client: CoolifyClient, project_uuid: str,
environment_name: str, server_uuid: str) -> str | None:
"""Create the API application from a Git repository via private deploy key.
Returns the application UUID or None on failure.
"""
private_key_uuid = os.environ.get("COOLIFY_PRIVATE_KEY_UUID", "")
if not private_key_uuid:
print(" Error: COOLIFY_PRIVATE_KEY_UUID not set")
return None
try:
resp = httpx.post(
f"{client.base_url}/api/v1/applications/private-deploy-key",
headers=client.headers,
json={
"project_uuid": project_uuid,
"environment_name": environment_name,
"server_uuid": server_uuid,
"private_key_uuid": private_key_uuid,
"git_repository": API_GIT_REPO,
"git_branch": API_GIT_BRANCH,
"build_pack": "dockerfile",
"name": APP_NAME,
"ports_exposes": "8000",
},
timeout=30,
)
if resp.status_code in (200, 201):
data = resp.json()
return data.get("uuid")
print(f" Error creating API application: {resp.status_code} {resp.text[:300]}")
return None
except Exception as e:
print(f" Error creating API application: {e}")
return None
# ─── Initial Deployment (create Coolify application from scratch) ──────
def deploy_initial() -> int:
"""Initial deployment: create all Coolify resources from scratch.
"""Initial deployment: create the Coolify application from scratch.
═══════════════════════════════════════════════════════════════════════
⚠️ KI / AGENT HINWEISE — BITTE VOR ÄNDERUNGEN LESEN ⚠️
@@ -1149,7 +679,7 @@ def deploy_initial() -> int:
environment_name = os.environ.get("COOLIFY_ENVIRONMENT", "production")
server_uuid = os.environ.get("COOLIFY_SERVER_UUID", "")
# ─── Single docker-compose application (like the original working app) ──
# ─── Single docker-compose application ──────────────────────────────
# All 4 containers (postgres, redis, crm-app, crm-worker) in one stack.
# Coolify reads docker-compose.yaml from the Git repo, builds images, all
# containers share one network. Service names work as DNS names. No manual
@@ -1315,7 +845,7 @@ def deploy_initial() -> int:
# Verification
print("\n[5/5] Running verification...")
verify_results = run_verification(client, worker_uuid=None)
verify_results = run_verification(client)
steps.extend(verify_results)
all_ok = print_summary(steps)
@@ -1331,9 +861,8 @@ def main() -> None:
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python scripts/deploy.py # Full deploy (API + Worker)
python scripts/deploy.py # Redeploy via Coolify API
python scripts/deploy.py --skip-build # Skip build, just restart
python scripts/deploy.py --worker-only # Only deploy worker
python scripts/deploy.py --verify-only # Only run verification
python scripts/deploy.py --initial # Create all resources from scratch
""",
@@ -1342,10 +871,6 @@ Examples:
"--skip-build", action="store_true",
help="Skip build, just restart services via Coolify API",
)
parser.add_argument(
"--worker-only", action="store_true",
help="Only deploy the worker service",
)
parser.add_argument(
"--initial", action="store_true",
help="Initial deployment: create all Coolify resources from scratch",
@@ -1360,8 +885,6 @@ Examples:
sys.exit(deploy_initial())
elif args.verify_only:
sys.exit(verify_only())
elif args.worker_only:
sys.exit(deploy_worker_only(skip_build=args.skip_build))
else:
sys.exit(deploy_full(skip_build=args.skip_build))