diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..51762c2 --- /dev/null +++ b/.env.docker.example @@ -0,0 +1,39 @@ +# ============================================================================= +# .env.docker.example — template for `docker compose --env-file .env.docker up` +# +# DO NOT COMMIT .env.docker. It contains real secrets. +# Usage: +# cp .env.docker.example .env.docker +# $EDITOR .env.docker +# docker compose --env-file .env.docker up --build +# ============================================================================= + +# --- PostgreSQL (local container) --------------------------------------------- +POSTGRES_USER=crm_user +# Generate a strong password, e.g.: +# python -c "import secrets; print(secrets.token_urlsafe(24))" +POSTGRES_PASSWORD=STRONG_PASSWORD_HERE +POSTGRES_DB=crm_db + +# --- CRM Application ---------------------------------------------------------- +# The host "postgres" is the docker-compose service name (internal DNS). +# The DRIVER is asyncpg for production PostgreSQL. +DATABASE_URL=postgresql+asyncpg://crm_user:STRONG_PASSWORD_HERE@postgres:5432/crm_db + +# --- AUTH_SECRET (REQUIRED, min 32 chars) ------------------------------------ +# JWT signing secret. MUST be at least 32 characters. +# Generate with: +# python -c "import secrets; print(secrets.token_urlsafe(48))" +AUTH_SECRET=MIN_32_CHARS_GENERATE_WITH_secrets_token_urlsafe_32_xxxxxxxxxxxx + +# --- CORS / environment ------------------------------------------------------- +# Comma-separated, NO wildcards. In dev we allow localhost:8000 (the app) and +# :5173 (e.g. Vite dev server). In production, restrict to the real domain. +CORS_ORIGINS=http://localhost:8000,http://localhost:5173 +ENVIRONMENT=production +LOG_LEVEL=INFO + +# --- JWT / bcrypt tuning (keep aligned with .env.example) --------------------- +JWT_ALGORITHM=HS256 +JWT_EXPIRY_HOURS=24 +BCRYPT_ROUNDS=12 diff --git a/.gitignore b/.gitignore index 42290f7..103d536 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .env .env.* !.env.example +!.env.docker.example # Python __pycache__/ diff --git a/COOLIFY_SETUP.md b/COOLIFY_SETUP.md new file mode 100644 index 0000000..5280cf3 --- /dev/null +++ b/COOLIFY_SETUP.md @@ -0,0 +1,269 @@ +# Coolify Setup — CRM System v1.0 + +Production deployment guide for the **CRM System** to the Coolify PaaS instance +at `server.media-on.de` (server UUID `lw80w8scs4044gwcw084s00s4`). + +The deploy consists of **two Coolify resources** in the same project/environment: + +1. A **PostgreSQL 16** database resource (one-click or Docker image). +2. The **crm-app** Application (Dockerfile build from a Git repository). + +The two 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). + +--- + +## 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 + +- 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:@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`). + +--- + +## 2. Resource A — PostgreSQL 16 database + +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 + `-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":"", +> "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:@: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`. | +| `JWT_ALGORITHM` | `HS256` | Aligned with `.env.example`. | +| `JWT_EXPIRY_HOURS` | `24` | Aligned with `.env.example`. | + +### Secret generation (run once, locally) + +```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))" +``` + +**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. + +> **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:@:5432/crm_db"}, +> {"key":"AUTH_SECRET", "value":""}, +> {"key":"CORS_ORIGINS", "value":"https://crm.media-on.de:443"}, +> {"key":"ENVIRONMENT", "value":"production"}, +> {"key":"LOG_LEVEL", "value":"INFO"}, +> {"key":"BCRYPT_ROUNDS", "value":"12"}, +> {"key":"JWT_ALGORITHM", "value":"HS256"}, +> {"key":"JWT_EXPIRY_HOURS", "value":"24"} +> ] +> }' +> ``` + +--- + +## 5. Configure the public domain (with port!) + +In **crm-app → Domains → + Add Domain**: + +- **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. + +> **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"}' +> ``` + +--- + +## 6. Healthcheck (Coolify side) + +In **crm-app → Advanced → Healthcheck**: + +- **Healthcheck path**: `/health` +- **Healthcheck method**: `GET` +- **Healthcheck interval**: `30s` +- **Healthcheck timeout**: `10s` +- **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. + +--- + +## 7. Build & deploy + +In the Coolify UI: **crm-app → Deployments → Deploy**. + +Watch the build log. The first deploy will: + +1. Clone the repo (branch `master`). +2. Build the multi-stage Dockerfile (≈ 1–2 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: + +```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 ` on protected routes +``` + +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 + +- 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` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..56a4049 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,74 @@ +# syntax=docker/dockerfile:1 + +# ============================================================================= +# CRM System v1.0 - Production Dockerfile +# Multi-stage build: builder (with build tools) + runtime (slim, non-root) +# Base: python:3.12-slim +# ============================================================================= + +# === Stage 1: Builder === +# Installs build dependencies (needed for compiling asyncpg, cryptography, etc.) +FROM python:3.12-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Build tools (gcc, libpq-dev) — needed for asyncpg + python-jose[cryptography] +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy ONLY requirements first for optimal layer caching +COPY requirements.txt . + +# Install all production dependencies into a user-local prefix +RUN pip install --user --no-cache-dir -r requirements.txt + +# === Stage 2: Runtime === +# Slim image, non-root user, no build tools +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PATH=/home/appuser/.local/bin:$PATH + +# Runtime dependencies: libpq5 (for asyncpg), curl (for healthcheck) +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libpq5 \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd -g 1000 appuser \ + && useradd -m -u 1000 -g appuser appuser + +WORKDIR /app + +# Copy installed Python packages from builder +COPY --from=builder /root/.local /home/appuser/.local + +# Copy application source (static files included via app/webui/) +COPY --chown=appuser:appuser . . + +# Make prestart.sh executable +RUN chmod +x /app/prestart.sh + +USER appuser + +# Internal port (Coolify/Traefik terminate SSL on 443 externally) +EXPOSE 8000 + +# Healthcheck: hits the root-level /health endpoint defined in app/main.py +# Interval 30s, timeout 10s, 3 retries, 15s start-period (migrations need time) +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD curl -fsS http://localhost:8000/health || exit 1 + +# Entrypoint runs DB migrations first, then starts uvicorn as PID 1 +ENTRYPOINT ["/app/prestart.sh"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c847438 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,88 @@ +# ============================================================================= +# docker-compose.yml — CRM System v1.0 (local testing with PostgreSQL) +# +# Use this file to run a full Postgres + crm-app stack locally, e.g. for +# 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 + +# a Coolify-managed Postgres database), NOT this file. +# +# Usage: +# cp .env.docker.example .env.docker +# $EDITOR .env.docker # fill AUTH_SECRET, POSTGRES_PASSWORD, ... +# docker compose --env-file .env.docker up --build +# curl http://localhost:8000/health +# ============================================================================= + +services: + # ------------------------------------------------------------------------- + # PostgreSQL 16 (Alpine) — local Postgres for development / smoke tests. + # Coolify will provision its own managed Postgres database in production. + # ------------------------------------------------------------------------- + postgres: + image: postgres:16-alpine + container_name: crm-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-crm_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + POSTGRES_DB: ${POSTGRES_DB:-crm_db} + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" # local-only convenience; remove for CI / prod-like runs + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-crm_user} -d ${POSTGRES_DB:-crm_db}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - crm-net + + # ------------------------------------------------------------------------- + # CRM App — built from the local Dockerfile. + # NOTE: no `image:` directive — we build from source on `docker compose up`. + # ------------------------------------------------------------------------- + crm-app: + build: + context: . + dockerfile: Dockerfile + container_name: crm-app + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + # Use the internal docker-compose DNS name "postgres" (NOT localhost) + DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} + AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET 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} + ENVIRONMENT: ${ENVIRONMENT:-production} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + # JWT settings — keep aligned with .env.example + JWT_ALGORITHM: ${JWT_ALGORITHM:-HS256} + JWT_EXPIRY_HOURS: ${JWT_EXPIRY_HOURS:-24} + BCRYPT_ROUNDS: ${BCRYPT_ROUNDS:-12} + ports: + - "8000:8000" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + networks: + - crm-net + +volumes: + pgdata: + name: crm_pgdata + +networks: + crm-net: + name: crm-net + driver: bridge diff --git a/prestart.sh b/prestart.sh new file mode 100755 index 0000000..117f446 --- /dev/null +++ b/prestart.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# ============================================================================= +# prestart.sh — Container entrypoint for CRM System +# +# Responsibilities: +# 1. Run Alembic DB migrations (alembic upgrade head). +# 2. Start uvicorn as PID 1 (so signals like SIGTERM are forwarded correctly). +# +# Notes: +# - `set -e` ensures the container crashes loudly if migrations fail, +# rather than starting a broken app on an inconsistent schema. +# - `exec uvicorn ...` replaces the shell process with uvicorn, so uvicorn +# becomes PID 1 and receives Docker's SIGTERM directly for graceful shutdown. +# - `--workers 1` is intentional for v1: SQLite (dev) is single-threaded, +# and asyncpg (prod) scales fine with one worker + an async connection pool. +# ============================================================================= + +set -e + +echo "[prestart] $(date -u +%Y-%m-%dT%H:%M:%SZ) - Running alembic upgrade head..." +alembic upgrade head +echo "[prestart] DB migrations completed successfully." + +echo "[prestart] Starting uvicorn on 0.0.0.0:8000 (workers=1)..." +exec uvicorn app.main:app \ + --host 0.0.0.0 \ + --port 8000 \ + --workers 1