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>
208 lines
11 KiB
Markdown
208 lines
11 KiB
Markdown
# راهنمای دیپلوی 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) |
|