Files
clinicpro/docs/DEPLOY.md
T
hamedandClaude Opus 4.8 5150365d2c harden(docker): non-root, dedicated healthcheck, opcache split, graceful shutdown
Coolify-doc-driven production hardening of the deploy stack:
- run the whole stack as non-root www-data; nginx on 8080 (non-privileged),
  pid in /tmp, user directive dropped (Coolify routes to any port)
- docker/healthcheck.sh: hit real /health route via PHP (not just port probe)
- split OPcache config into docker/php/opcache.ini
- graceful shutdown: supervisord stopsignal/stopwaitsecs + worker stop_grace_period
- APCu intentionally not added (Symfony cache uses redis)
- DEPLOY.md: 8080 port, non-root, resource-limit guidance

Verified on linux/amd64: non-root uid=82, /health 200, migrations run,
worker process healthcheck OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:27:34 +03:30

208 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# راهنمای دیپلوی ClinicPro (Coolify + Docker Compose)
این راهنما برای دیپلوی بک‌اند ClinicPro روی **Coolify** با استفاده از `docker-compose.yml` نوشته شده.
> توجه: این `docker-compose.yml` فقط برای **production** است. محیط لوکال از `compose.yaml` خودِ ddev استفاده می‌کند — این دو را با هم اشتباه نگیر.
---
## معماری استک
`docker-compose.yml` فقط سرویس‌های **اپلیکیشن** را بالا می‌آورد. **MariaDB و Redis جداگانه** به‌صورت Database Resource مستقل Coolify اجرا می‌شوند (نه داخل این compose):
| سرویس | نقش | نکته |
|---|---|---|
| `app` | PHP-FPM + Nginx (وب، non-root، پورت ۸۰۸۰) | تنها سرویسی که `RUN_INIT=1` دارد؛ مهاجرت DB و تولید کلید JWT را اجرا می‌کند. دامنه‌ها را به این سرویس (پورت ۸۰۸۰) وصل کن. |
| `worker-async` | مصرف‌کننده صف async (SMS و کارهای async) | `messenger:consume async` |
| `worker-scheduler` | زمان‌بند | هر ۱ دقیقه نوبت‌های پرداخت‌نشده را منقضی می‌کند |
| Resource مستقل Coolify | نقش |
|---|---|
| **MariaDB 11.8** (Database Resource جدا) | پایگاه‌داده — مدیریت/بکاپ/ری‌استارت مستقل |
| **Redis** (Database Resource جدا) | صف Messenger + کش |
**ولوم‌های ماندگار استک اپ** (در ری‌دیپلوی حفظ می‌شوند):
- `jwt_keys` → کلیدهای JWT
- `uploads_public` و `uploads_var` → فایل‌های آپلودی
> داده‌ی MariaDB و Redis توسط خودِ Resourceهای مستقل نگه داشته می‌شود (volume در آن‌ها، نه در این استک).
---
## پیش‌نیازها
- نمونه‌ی Coolify در حال اجرا با Traefik (پیش‌فرض Coolify).
- ریپوی Git متصل به Coolify.
- رکوردهای DNS برای دامنه‌ی API و همه‌ی دامنه‌های فرانت‌اند که به سرور اشاره کنند.
---
## مرحله ۱ — ساخت دیتابیس‌های مستقل (MariaDB + Redis)
اول این دو Resource را جدا بساز (قبل از استک اپ):
1. **New Resource → Database → MariaDB**، نسخه **11.8** (باید با `serverVersion` در `DATABASE_URL` و migrationها هم‌خوان باشد). نام دیتابیس `clinic_pro`، یوزر `clinic`، یک رمز قوی ست کن.
2. **New Resource → Database → Redis**.
3. از صفحه‌ی هر Resource، **Internal URL / hostname** (به شکل `mariadb-<uuid>` و `redis-<uuid>`) و credentials را یادداشت کن — در مرحله ۴ لازم می‌شود.
---
## مرحله ۲ — ساخت منبع اپ (Resource) در Coolify
1. **New Resource → Docker Compose** (Build Pack: `Docker Compose`).
2. ریپو و برنچ را انتخاب کن.
3. فیلد **Compose file** را روی `docker-compose.yml` بگذار.
4. **"Connect to Predefined Network"** را روی این استک **فعال کن** — تا اپ بتواند به Resourceهای مستقل MariaDB/Redis (که در شبکه‌ی دیگری هستند) وصل شود.
5. `networks:` سفارشی تعریف **نکن** — شبکه را Coolify مدیریت می‌کند؛ شبکه‌ی سفارشی روتینگ Traefik را می‌شکند.
---
## مرحله ۳ — دامنه‌ها
همه‌ی دامنه‌های سرو شونده را به سرویس **`app`** اختصاص بده — هم دامنه‌ی API و هم همه‌ی دامنه‌های فرانت‌اند. Coolify لیست دامنه‌ی جدا‌شده با کاما را روی یک سرویس قبول می‌کند و TLS را خودش صادر می‌کند.
> ⚠️ **پورت سرویس = `8080`** (نه ۸۰). کانتینر non-root اجرا می‌شود و nginx روی پورت غیرممتاز ۸۰۸۰ گوش می‌دهد. در Coolify port سرویس `app` را روی **۸۰۸۰** بگذار (Traefik به هر پورتی روت می‌کند — طبق داک، هر پورتی مجاز است).
> اجازه‌دادن CORS و host فرانت‌اندها از طریق متغیرهای `CORS_ALLOW_ORIGIN` / `ALLOWED_FRONTEND_HOSTS` کنترل می‌شود، نه دامنه‌ی Coolify.
---
## مرحله ۴ — متغیرهای محیطی
از `.env.coolify.example` کپی کن و در تب **Environment Variables** منبع اپ Coolify بگذار.
### اتصال به دیتابیس‌های مستقل (الزامی)
چون MariaDB/Redis جدا هستند، رشته‌های اتصال **اینجا** ست می‌شوند و به hostname داخلی Resource اشاره می‌کنند (`mariadb-<uuid>` / `redis-<uuid>` از مرحله ۱):
```bash
DATABASE_URL="mysql://clinic:DB_PASSWORD@mariadb-XXXXXXXX:3306/clinic_pro?serverVersion=mariadb-11.8.0&charset=utf8mb4"
REDIS_URL="redis://redis-XXXXXXXX:6379"
MESSENGER_TRANSPORT_DSN="redis://redis-XXXXXXXX:6379/messages"
# اگر Redis رمز دارد: redis://:PASSWORD@redis-XXXXXXXX:6379
```
> `serverVersion=mariadb-11.8.0` باید با نسخه‌ی Resource مستقل MariaDB یکی باشد.
### اسرار (الزامی — قبل از اولین دیپلوی)
```bash
APP_SECRET= # php -r "echo bin2hex(random_bytes(32));"
JWT_PASSPHRASE= # openssl rand -hex 32 (باید قبل از اولین استارت موجود باشد؛ کلید JWT با همین ساخته می‌شود)
```
> رمز DB دیگر اینجا (`DB_PASSWORD`/`DB_ROOT_PASSWORD`) ست نمی‌شود — هنگام ساخت Resource مستقل MariaDB تعیین می‌شود و داخل `DATABASE_URL` بالا قرار می‌گیرد.
> ⚠️ `JWT_PASSPHRASE` را بعد از اولین دیپلوی عوض نکن — کلید JWT یک‌بار با همین passphrase تولید و روی ولوم `jwt_keys` ماندگار می‌شود. تغییرش همه‌ی توکن‌ها را می‌شکند.
در Coolify می‌توانی به‌جای هاردکد از magic var استفاده کنی:
```bash
APP_SECRET=${SERVICE_HEX_APPSECRET}
```
### دامنه‌ها و CORS
```bash
APP_BASE_URL=https://api.nobat724.com # دامنه‌ی خودِ بک‌اند (برای callback پرداخت و URLهای مطلق)
```
`ALLOWED_FRONTEND_HOSTS` و `CORS_ALLOW_ORIGIN` از `docker/frontend-domains.json` **تولید** می‌شوند. برای اضافه/حذف دامنه‌ی شهر:
```bash
# آن فایل را ویرایش کن، سپس:
php docker/gen-cors-env.php # روی سرور
# یا لوکال:
ddev exec php docker/gen-cors-env.php
```
خروجی را در Coolify جایگزین کن.
### ریورس‌پراکسی
```bash
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1
```
تا Symfony به هدرهای `X-Forwarded-*` ترافیک اعتماد کند.
### api.ir (استعلام هویت — Shahkar / IbanMatch)
```bash
API_IR_BASE_URL=https://s.api.ir
API_IR_TOKEN= # خالی => fail-closed (تأیید نماینده رد می‌شود)
```
### چیزهایی که env لازم ندارند
- کلیدهای SMS (kavenegar/rangineh) و درگاه پرداخت (mellat/sep) از **DB ("تنظیمات سایت")** خوانده می‌شوند، نه env.
- `REFRESH_TOKEN_TTL` / `OTP_TTL` / `MAX_FILE_SIZE_BYTES` در خود compose ثابت‌اند.
---
## مرحله ۵ — دیپلوی
روی **Deploy** بزن. در اولین استارت به‌صورت خودکار این‌ها اتفاق می‌افتد (`entrypoint.sh` + `RUN_INIT=1` روی سرویس `app`):
1. مالکیت `var`, `public/uploads`, `config/jwt` به `www-data` داده می‌شود.
2. منتظر آماده‌شدن DB مستقل می‌ماند (تا ~۶۰ ثانیه؛ چون دیگر `depends_on: service_healthy` نیست).
3. کلید JWT اگر روی ولوم نباشد ساخته می‌شود (`--skip-if-exists`).
4. کش prod پاک و warmup می‌شود.
5. مهاجرت‌های DB با `--all-or-nothing` اعمال می‌شوند (ترنزکشن).
> ورکرها `RUN_INIT=0` دارند تا مهاجرت/تولید کلید با هم تداخل نکنند.
---
## مرحله ۶ — پس از اولین دیپلوی
### ساخت اولین ادمین
```bash
# داخل کانتینر سرویس app
php bin/console app:create-admin
```
### بررسی سلامت
- healthcheck سرویس `app`: `docker/healthcheck.sh` route واقعی `/health` را روی پورت ۸۰۸۰ می‌زند (نه فقط چک پورت).
- workerها: healthcheck زنده‌بودن پروسه‌ی `messenger:consume` با `ps`.
- Swagger: `https://<APP_BASE_URL>/api/doc`
- پنل ادمین: `https://<APP_BASE_URL>/admin`
---
## امنیت و منابع
- **non-root:** کل استک (supervisord + php-fpm + nginx) با کاربر `www-data` اجرا می‌شود؛ nginx روی پورت غیرممتاز ۸۰۸۰. هیچ پروسه‌ای root نیست.
- **بدون secret در image/repo:** همه‌ی مقادیر حساس از تب Environment Variables کولیفای (`${...}``.env` مخزن gitignore است و در image یک `.env` حداقلی فقط `APP_ENV=prod` ساخته می‌شود.
- **Resource Limits:** داک Coolify limits را از UI منبع می‌گیرد (نه لزوماً از compose). پیشنهاد شروع:
- `app`: حافظه ~۵۱۲MB–۱GB، CPU ~۱
- هر worker: حافظه ~۲۵۶MB، CPU ~۰٫۵
در صفحه‌ی هر سرویس Coolify تنظیم کن و با مصرف واقعی تنظیم نهایی کن.
---
## دیپلوی‌های بعدی
push روی برنچ متصل (یا Deploy دستی). در هر ری‌دیپلوی:
- ایمیج دوباره build می‌شود (vendor + اسمبل فرانت‌اند multi-stage).
- مهاجرت‌های جدید روی استارت `app` اعمال می‌شوند.
- ولوم‌های استک اپ (آپلودها، کلیدهای JWT) حفظ می‌شوند. داده‌ی MariaDB/Redis در Resourceهای مستقل مستقل از این ری‌دیپلوی سالم می‌ماند.
---
## عیب‌یابی
| نشانه | علت محتمل |
|---|---|
| ارورهای CORS در فرانت | `CORS_ALLOW_ORIGIN` با دامنه نمی‌خواند؛ از `gen-cors-env.php` بازتولید کن |
| `app` با «waiting for database...» می‌ماند و بعد ۶۰ ثانیه می‌میرد | اپ به Resource مستقل MariaDB نمی‌رسد؛ چک کن: «Connect to Predefined Network» فعال است، hostname در `DATABASE_URL` درست (`mariadb-<uuid>`) و رمز/نسخه‌ی Resource درست است |
| `redis` در دسترس نیست / صف کار نمی‌کند | `REDIS_URL`/`MESSENGER_TRANSPORT_DSN` به `redis-<uuid>` درست اشاره نمی‌کند یا رمز جا افتاده |
| ۴۰۱/توکن نامعتبر بعد از ری‌دیپلوی | `JWT_PASSPHRASE` تغییر کرده یا ولوم `jwt_keys` پاک شده |
| IPها/HTTPS اشتباه پشت پراکسی | `TRUSTED_PROXIES` ست نشده |
| مهاجرت اجرا نشد | فقط `app` با `RUN_INIT=1` اجرا می‌کند؛ مطمئن شو override نشده |
| تأیید نماینده رد می‌شود | `API_IR_TOKEN` خالی است (fail-closed) |