feat(phase-4d): deployment (Dockerfile, compose, coolify-setup, runbook)

- Dockerfile: multi-stage build (python:3.12-slim), non-root appuser (UID 1000),
  HEALTHCHECK on /health (30s/10s/3retries/15s start-period), ENTRYPOINT
  prestart.sh runs alembic upgrade head then uvicorn (1 worker)
- prestart.sh: set -e, alembic upgrade head, exec uvicorn as PID 1
- docker-compose.yml: postgres:16-alpine + crm-app build from local Dockerfile,
  depends_on service_healthy, no 'image:' (build from source)
- .env.docker.example: template (placeholders, NOT real secrets)
- COOLIFY_SETUP.md: deployment guide, domain format https://crm.media-on.de:443
  (port is MANDATORY for Let's Encrypt + Traefik routing), AUTH_SECRET min 32 chars
- .gitignore: allow .env.docker.example template (was matched by .env.*)

118 tests still pass. No backend code touched.
This commit is contained in:
CRM Bot
2026-06-03 22:20:43 +00:00
parent 86dac33663
commit d68d385339
6 changed files with 499 additions and 0 deletions
+39
View File
@@ -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
+1
View File
@@ -2,6 +2,7 @@
.env
.env.*
!.env.example
!.env.docker.example
# Python
__pycache__/
+269
View File
@@ -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:<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`).
---
## 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
`<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`. |
| `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:<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"},
> {"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 (≈ 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:
```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
```
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`
+74
View File
@@ -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"]
+88
View File
@@ -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
Executable
+28
View File
@@ -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