feat: Implement Docker-based deployment for ClinicPro on Coolify

- Added Dockerfile for multi-stage build including PHP, Node.js, and Nginx.
- Created docker-compose.coolify.yaml for service orchestration with app, workers, MariaDB, and Redis.
- Introduced entrypoint.sh for initialization tasks like JWT key generation and database migrations.
- Configured Nginx with default.conf for handling requests and routing to PHP-FPM.
- Added php.ini with production settings and opcache configuration.
- Set up supervisord.conf to manage PHP-FPM and Nginx processes.
- Created frontend-domains.json for managing allowed frontend domains.
- Added gen-cors-env.php script to generate CORS environment variables from frontend domains.
- Updated framework.yaml to configure trusted proxies and headers.
- Created .dockerignore to exclude unnecessary files from the Docker context.
- Added .env.coolify.example for environment variable configuration.
- Documented deployment steps and troubleshooting in coolify.md.
This commit is contained in:
hamed
2026-06-25 21:27:28 +03:30
parent dfda265af4
commit cffc88db05
13 changed files with 940 additions and 147 deletions
+331 -147
View File
@@ -1,205 +1,389 @@
# آماده‌سازی پروژه ClinicPro برای دیپلوی روی Coolify (Symfony + Nixpacks)
# آماده‌سازی پروژه ClinicPro برای دیپلوی روی Coolify با Docker Compose
## زمینه
پروژه قرار است روی **Coolify** با Build Pack = **Nixpacks** دیپلوی شود (طبق https://coolify.io/docs/applications/symfony).
در حال حاضر پروژه فقط برای محیط لوکال **ddev** پیکربندی شده است (`compose.yaml`، `.ddev/`).
برای Coolify نیاز به این موارد داریم که هیچ‌کدام الان وجود ندارند:
پروژه روی **Coolify** دیپلوی می‌شود، اما به‌جای Build Pack پیش‌فرض (Nixpacks)، از **Docker Compose** به‌عنوان build pack استفاده می‌کنیم — یعنی یک `docker-compose.yaml` در ریشهٔ پروژه «single source of truth» است و Coolify آن را اجرا می‌کند (طبق https://coolify.io/docs/builds/packs/docker-compose).
1. کنترل کامل بر فرایند **build** — چون این پروژه علاوه بر PHP یک فرانت‌اند **React 19 + Webpack Encore** دارد که باید با `yarn build` کامپایل شود (Nixpacks پیش‌فرض Symfony فقط PHP را build می‌کند و دارایی‌های `public/build/` ساخته نمی‌شوند).
2. تولید **کلیدهای JWT** (lexik) هنگام دیپلوی — این کلیدها در `.gitignore` هستند و در ریپو نیستند.
3. اجرای **migrations** بعد از هر دیپلوی.
4. اجرای **worker**های Messenger:
- `messenger:consume async` → ارسال SMS
- `messenger:consume scheduler_default` → انقضای نوبت‌های پرداخت‌نشده (هر ۱ دقیقه)
5. تنظیم **Trusted Proxies** (چون Coolify پشت Traefik/reverse-proxy است؛ بدون آن `https`، IP کلاینت و کوکی‌ها درست کار نمی‌کنند).
6. یک فایل **`.env.coolify.example`** که همهٔ متغیرهای محیطی لازم را برای وارد کردن در داشبورد Coolify فهرست کند.
این یعنی ما باید **همهٔ image‌ها و سرویس‌ها را خودمان تعریف کنیم**: یک Dockerfile مرحله‌ای (multi-stage) برای ساخت اپ، و یک compose که اپ + worker‌ها را بالا بیاورد. هیچ‌کدام از این فایل‌ها الان وجود ندارند.
نکتهٔ مهم معماری: دیتابیس پروژه **MariaDB/MySQL** است (نه PostgreSQL که داک Coolify مثال می‌زند). Redis برای Messenger و OTP استفاده می‌شود.
**وضعیت فعلی پروژه:**
- Symfony 7.4 / PHP ≥8.2 / Doctrine.
- دیتابیس **MariaDB/MySQL** (نه PostgreSQL — توجه: `compose.yaml` موجود از postgres است ولی آن مخصوص ddev و نامرتبط است؛ پروژهٔ واقعی روی MariaDB 11.8 با `DATABASE_URL=mysql://...` کار می‌کند).
- فرانت‌اند **React 19 + Webpack Encore** که باید با `yarn build` کامپایل شود → خروجی در `public/build/`.
- کلیدهای **JWT** (lexik) در `config/jwt/*.pem`، در `.gitignore`.
- دو **worker** Messenger:
- `messenger:consume async` → ارسال SMS
- `messenger:consume scheduler_default` → انقضای نوبت‌های پرداخت‌نشده (هر ۱ دقیقه؛ transport `schedule://default`)
- `public/index.php` نقطهٔ ورود است. کامند ساخت ادمین: `app:create-admin`.
**نکتهٔ مهم دربارهٔ ddev:** فایل‌های `compose.yaml`، `compose.override.yaml` و پوشهٔ `.ddev/` مخصوص محیط لوکال هستند و **نباید دست بخورند**. فایل دیپلوی Coolify باید نام متفاوتی داشته باشد (`docker-compose.coolify.yaml`) تا با ddev تداخل نکند؛ در Coolify UI همین فایل را به‌عنوان Compose file مشخص می‌کنیم.
## هدف
پروژه را طوری «coolify-ready» کن که با Build Pack = Nixpacks و چند متغیر محیطی، بدون تغییر در منطق برنامه، روی Coolify بالا بیاید — شامل build فرانت‌اند، تولید کلید JWT، migration، و راه‌اندازی worker‌ها.
ساخت یک image تولیدی کامل (PHP-FPM + Nginx + دارایی‌های build‌شدهٔ فرانت‌اند) و یک `docker-compose.coolify.yaml` که روی Coolify با Docker Compose build pack بالا بیاید — شامل اپ وب، دو worker، و اتصال به سرویس‌های MariaDB و Redis. **بدون تغییر منطق برنامه.**
## فایل‌های مرتبط
| فایل | نقش | وضعیت |
|------|-----|-------|
| `nixpacks.toml` | کنترل فازهای build/install/start برای Nixpacks | **باید ساخته شود** |
| `config/packages/framework.yaml` | افزودن `trusted_proxies` و `trusted_headers` | باید ویرایش شود |
| `.env.coolify.example` | فهرست کامل env برای داشبورد Coolify | **باید ساخته شود** |
| `docs/deploy/coolify.md` | راهنمای گام‌به‌گام دیپلوی | **باید ساخته شود** |
| `.env.example` | مرجع موجود متغیرها (فقط خواندن) | بدون تغییر |
| `package.json` | اسکریپت `build` = `encore production` | بدون تغییر |
| `config/packages/messenger.yaml` | تعریف transport های `async` و `scheduler_default` | بدون تغییر (مرجع worker) |
## وضعیت فعلی
- `package.json` اسکریپت build دارد: `"build": "encore production --progress"`.
- خروجی build در `public/build/` می‌رود (در `.gitignore` است → باید حین دیپلوی ساخته شود).
- کلیدهای JWT در `config/jwt/*.pem` (در `.gitignore`).
- `framework.yaml` فعلی هیچ `trusted_proxies` ندارد:
```yaml
framework:
secret: '%env(APP_SECRET)%'
session: true
```
- متغیرهای کلیدی از `.env.example`: `APP_ENV`, `APP_SECRET`, `DATABASE_URL` (mysql), `JWT_*`, `CORS_ALLOW_ORIGIN`, `MESSENGER_TRANSPORT_DSN` (redis), `REDIS_URL`, `API_IR_*`, `APP_BASE_URL`, `ALLOWED_FRONTEND_HOSTS`, `DEFAULT_URI`, `MAX_FILE_SIZE_BYTES`, `UPLOAD_DIR`.
| `Dockerfile` | image مرحله‌ای: composer + yarn build → runtime PHP-FPM + Nginx | **باید ساخته شود** |
| `docker-compose.coolify.yaml` | تعریف سرویس‌های app/worker برای Coolify | **باید ساخته شود** |
| `docker/nginx/default.conf` | کانفیگ Nginx برای Symfony (`public/index.php`) | **باید ساخته شود** |
| `docker/php/php.ini` | تنظیمات production PHP (upload size, memory) | **باید ساخته شود** |
| `docker/entrypoint.sh` | migration + تولید JWT key هنگام استارت | **باید ساخته شود** |
| `docker/supervisord.conf` | اجرای php-fpm + nginx در یک کانتینر | **باید ساخته شود** |
| `.dockerignore` | حذف node_modules/vendor/var از build context | **باید ساخته شود** |
| `config/packages/framework.yaml` | افزودن `trusted_proxies` / `trusted_headers` | باید ویرایش شود |
| `.env.coolify.example` | فهرست env برای داشبورد Coolify | **باید ساخته شود** |
| `docs/deploy/coolify.md` | راهنمای گام‌به‌گام | **باید ساخته شود** |
## وظایف
### ۱. ساخت `nixpacks.toml` در ریشهٔ پروژه
### ۱. ساخت `Dockerfile` چندمرحله‌ای
هدف: علاوه بر نصب وابستگی‌های PHP، فرانت‌اند را هم build کن و کلید JWT بساز. Nixpacks باید هم PHP و هم Node/Yarn را در محیط build داشته باشد.
سه stage: یکی برای وابستگی‌های PHP (composer)، یکی برای build فرانت‌اند (node/yarn)، و stage نهایی runtime.
```toml
# nixpacks.toml — کنترل دیپلوی Coolify
[phases.setup]
nixPkgs = ["php82", "php82Packages.composer", "nodejs_20", "yarn", "openssl"]
```dockerfile
# ---------- Stage 1: PHP vendor (composer) ----------
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
# نصب بدون اسکریپت‌ها (kernel هنوز کامل کپی نشده)
RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist --optimize-autoloader
[phases.install]
cmds = [
"composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist",
"yarn install --frozen-lockfile"
]
# ---------- Stage 2: Frontend assets (yarn/encore) ----------
FROM node:20-alpine AS assets
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY webpack.config.js postcss.config.js tsconfig.json ./
COPY assets ./assets
# vendor لازم است چون encore به برخی bundleها رجوع می‌کند؛ در صورت نیاز کپی کن
COPY --from=vendor /app/vendor ./vendor
COPY public ./public
RUN yarn build # خروجی → public/build
[phases.build]
# build دارایی‌های فرانت‌اند (React/Encore) → public/build
# و تولید کلید JWT اگر وجود نداشته باشد (idempotent)
cmds = [
"yarn build",
"php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction"
]
# ---------- Stage 3: Runtime (PHP-FPM + Nginx) ----------
FROM php:8.2-fpm-alpine AS runtime
RUN apk add --no-cache nginx supervisor icu-dev oniguruma-dev $PHPIZE_DEPS \
&& docker-php-ext-install pdo_mysql intl opcache \
&& apk del $PHPIZE_DEPS
# (در صورت نیاز redis extension: pecl install redis && docker-php-ext-enable redis)
[start]
# Nixpacks خودش php-fpm/nginx را با NIXPACKS_PHP_ROOT_DIR اجرا می‌کند؛
# این بخش را فقط در صورت نیاز override کن. در حالت عادی خالی بماند.
WORKDIR /app
COPY . .
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
COPY docker/php/php.ini /usr/local/etc/php/conf.d/zz-app.ini
COPY docker/nginx/default.conf /etc/nginx/http.d/default.conf
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh \
&& mkdir -p var/cache var/log var/uploads public/uploads config/jwt \
&& chown -R www-data:www-data var public/uploads config/jwt
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
```
**نکات:**
- نسخهٔ `php82` باید با `"php": ">=8.2"` در `composer.json` هم‌خوان باشد. اگر Nixpacks نسخهٔ PHP را خودکار از composer تشخیص می‌دهد، می‌توان `php` را از `nixPkgs` حذف کرد — ولی صریح بودن امن‌تر است.
- `lexik:jwt:generate-keypair --skip-if-exists` باعث می‌شود اگر کلید از قبل (مثلاً از volume) وجود داشته باشد، دوباره ساخته نشود. **هشدار را در docs ذکر کن:** اگر کلید JWT بین دیپلوی‌ها persist نشود، همهٔ توکن‌های صادرشده باطل می‌شوند → بهتر است `JWT_SECRET_KEY`/`JWT_PUBLIC_KEY` به‌صورت محتوای base64 در env داده شوند یا روی volume مانت شوند. هر دو گزینه را در docs توضیح بده.
- اگر `JWT_PASSPHRASE` در env تنظیم نشده باشد، تولید کلید fail می‌شود — این را در docs قید کن.
- نسخهٔ PHP باید `8.2` باشد (هم‌خوان با `"php": ">=8.2"`).
- اکستنشن‌های لازم: `pdo_mysql` (MariaDB)، `intl` (Symfony)، `opcache`. اگر کد از `predis` استفاده می‌کند نیازی به اکستنشن redis نیست؛ بررسی کن `composer.json` چه دارد (`symfony/cache` با redis adapter ممکن است اکستنشن بخواهد) — اگر `\Redis` استفاده می‌شود، اکستنشن redis را نصب کن.
- چون فاز vendor با `--no-scripts` نصب می‌کند، در entrypoint یا با `composer run-script` کش warm شود (یا `cache:warmup` در entrypoint).
- بررسی کن آیا stage assets واقعاً به `vendor` نیاز دارد (به‌خاطر `@symfony/webpack-encore` و `symfony/ux-react`). اگر بدون vendor هم build می‌شود، آن COPY را حذف کن تا سبک‌تر شود.
### ۲. افزودن Trusted Proxies به `config/packages/framework.yaml`
### ۲. ساخت `docker/entrypoint.sh`
Coolify پشت Traefik است. بدون این تنظیم، Symfony پروتکل `https` و IP واقعی را تشخیص نمی‌دهد.
کارهای استارت‌آپ که نباید در build انجام شوند (چون به env و DB زنده نیاز دارند):
```sh
#!/bin/sh
set -e
# فقط برای سرویس وب اصلی، نه worker‌ها (worker‌ها CMD خودشان را دارند)
if [ "${RUN_INIT:-1}" = "1" ]; then
# تولید کلید JWT اگر persist نشده (idempotent)
php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction || true
# warmup کش prod
php bin/console cache:clear --no-warmup || true
php bin/console cache:warmup || true
# migration (idempotent)
php bin/console doctrine:migrations:migrate --all-or-nothing --no-interaction || true
fi
exec "$@"
```
**نکته:** migration را یک‌بار اجرا کن. اگر سه سرویس (web + 2 worker) همگی همین entrypoint را اجرا کنند، race می‌شود → فقط سرویس web متغیر `RUN_INIT=1` داشته باشد و worker‌ها `RUN_INIT=0`. این را در compose منعکس کن. `--all-or-nothing` همان چیزی است که داک Coolify توصیه کرده.
### ۳. ساخت `docker/supervisord.conf`
برای اجرای هم‌زمان php-fpm و nginx در کانتینر web:
```ini
[supervisord]
nodaemon=true
[program:php-fpm]
command=php-fpm -F
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nginx]
command=nginx -g 'daemon off;'
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
```
### ۴. ساخت `docker/nginx/default.conf`
کانفیگ استاندارد Symfony front-controller:
```nginx
server {
listen 80;
server_name _;
root /app/public;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass 127.0.0.1:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
internal;
}
location ~ \.php$ { return 404; }
client_max_body_size 16m; # هماهنگ با MAX_FILE_SIZE_BYTES
error_log /dev/stderr;
access_log /dev/stdout;
}
```
### ۵. ساخت `docker/php/php.ini`
```ini
memory_limit = 256M
upload_max_filesize = 16M
post_max_size = 16M
max_execution_time = 60
expose_php = Off
opcache.enable = 1
opcache.preload = /app/config/preload.php
opcache.preload_user = www-data
```
**نکته:** `config/preload.php` در پروژه موجود است (تأیید شد) — می‌توان preload را فعال کرد. اگر باعث خطا شد، خط preload را حذف کن.
### ۶. ساخت `.dockerignore`
```
/node_modules
/vendor
/var
/public/build
/public/uploads
/.git
/.ddev
/.claude
/tests
*.sql
*.sql.gz
.env.local
.env.*.local
```
### ۷. ساخت `docker-compose.coolify.yaml`
سرویس‌ها: `app` (web)، `worker-async`، `worker-scheduler`. دیتابیس و Redis را **به‌عنوان سرویس مدیریت‌شدهٔ جداگانه در Coolify** بساز و از طریق env متصل کن — یا اگر می‌خواهی همه در compose باشند، MariaDB و Redis را هم اضافه کن. روش پیشنهادی: دیتابیس/Redis در همین compose تا «single source of truth» باشد.
```yaml
services:
app:
build:
context: .
dockerfile: Dockerfile
environment:
RUN_INIT: "1"
# SERVICE_FQDN_APP → دامنه از UI کولیفای ست می‌شود (پورت 80)
- APP_ENV=prod
- APP_DEBUG=0
- APP_SECRET=${APP_SECRET}
- DATABASE_URL=mysql://clinic:${DB_PASSWORD}@mariadb:3306/clinic_pro?serverVersion=mariadb-11.8.0&charset=utf8mb4
- MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages
- REDIS_URL=redis://redis:6379
- JWT_PASSPHRASE=${JWT_PASSPHRASE}
- CORS_ALLOW_ORIGIN=${CORS_ALLOW_ORIGIN}
- DEFAULT_URI=${APP_BASE_URL}
- APP_BASE_URL=${APP_BASE_URL}
- ALLOWED_FRONTEND_HOSTS=${ALLOWED_FRONTEND_HOSTS}
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
- API_IR_BASE_URL=${API_IR_BASE_URL}
- API_IR_TOKEN=${API_IR_TOKEN}
- REFRESH_TOKEN_TTL=2592000
- OTP_TTL=1200
- MAX_FILE_SIZE_BYTES=5242880
- UPLOAD_DIR=var/uploads
volumes:
- jwt_keys:/app/config/jwt
- uploads_public:/app/public/uploads
- uploads_var:/app/var/uploads
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_started
# پورت 80 — دامنه را در Coolify UI به این سرویس بده
worker-async:
build:
context: .
dockerfile: Dockerfile
command: php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M -vv
environment:
RUN_INIT: "0"
# همان env بالا (از .env کولیفای interpolate می‌شود)
...
volumes:
- jwt_keys:/app/config/jwt
- uploads_public:/app/public/uploads
- uploads_var:/app/var/uploads
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_started
worker-scheduler:
build:
context: .
dockerfile: Dockerfile
command: php bin/console messenger:consume scheduler_default --time-limit=3600 -vv
environment:
RUN_INIT: "0"
...
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_started
mariadb:
image: mariadb:11.8
environment:
- MARIADB_DATABASE=clinic_pro
- MARIADB_USER=clinic
- MARIADB_PASSWORD=${DB_PASSWORD}
- MARIADB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
jwt_keys:
uploads_public:
uploads_var:
mariadb_data:
redis_data:
```
**نکات حیاتی برای compose:**
- **هیچ `networks:` سفارشی تعریف نکن** — داک Coolify صریحاً هشدار داده که شبکهٔ سفارشی باعث قطعی متناوب در روتینگ Traefik می‌شود. Coolify خودش شبکه می‌سازد.
- برای جلوگیری از تکرار env در سه سرویس، می‌توان از YAML anchor (`x-app-env: &app-env`) استفاده کرد و در هر سرویس `<<: *app-env`. این را پیاده کن تا فایل تمیز بماند.
- متغیرهای حساس (`APP_SECRET`, `DB_PASSWORD`, `JWT_PASSPHRASE`, `API_IR_TOKEN`) با `${...}` از env کولیفای خوانده می‌شوند، نه hardcode. می‌توان از magic variableهای کولیفای مثل `${SERVICE_PASSWORD_DB}` برای پسورد دیتابیس استفاده کرد — این را در docs توضیح بده.
- worker‌ها `RUN_INIT=0` دارند تا فقط سرویس `app` migration/JWT را اجرا کند (جلوگیری از race).
- volume `jwt_keys` تضمین می‌کند کلید JWT بین دیپلوی‌ها persist شود؛ هر سه سرویسی که توکن می‌سازند/می‌خوانند باید همین volume را mount کنند.
### ۸. افزودن Trusted Proxies به `config/packages/framework.yaml`
Coolify پشت Traefik است؛ بدون این تنظیم `https` و IP کلاینت اشتباه تشخیص داده می‌شود.
```yaml
framework:
secret: '%env(APP_SECRET)%'
session: true
# Coolify/Traefik reverse proxy
trusted_proxies: '%env(TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']
```
و در `.env.coolify.example` مقدار پیش‌فرض امن بده (طبق داک Coolify که از REMOTE_ADDR یا رنج شبکهٔ داکر استفاده می‌شود):
مقدار پیش‌فرض در `.env.coolify.example` (رنج شبکهٔ داخلی داکر):
```
# IP/رنج reverse proxy؛ برای Coolify معمولاً رنج شبکهٔ داخلی داکر یا 'REMOTE_ADDR'
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1
```
**نکته:** اگر `TRUSTED_PROXIES` خالی بماند، Symfony خطا نمی‌دهد ولی پشت پراکسی درست کار نمی‌کند؛ مقدار پیش‌فرض را در env بگذار تا اگر کاربر فراموش کرد، رفتار منطقی باشد. اگر ترجیح می‌دهی fallback داشته باشی از `'%env(default::TRUSTED_PROXIES)%'` با مقدار پیش‌فرض در `.env` استفاده کن.
### ۹. ساخت `.env.coolify.example`
### ۳. ساخت `.env.coolify.example`
فهرست کامل و دسته‌بندی‌شدهٔ تمام متغیرهایی که باید در داشبورد Coolify (Environment Variables) وارد شوند. از روی `.env.example` بساز ولی این موارد Coolify-specific را اضافه کن:
فهرست تمام متغیرهایی که در داشبورد Coolify باید وارد شوند (آن‌هایی که در compose با `${...}` ارجاع شده‌اند):
```env
# ───── Coolify / Nixpacks (الزامی طبق داک Coolify) ─────
APP_ENV=prod
APP_DEBUG=0
NIXPACKS_PHP_FALLBACK_PATH=/index.php
NIXPACKS_PHP_ROOT_DIR=/app/public
# Port Exposure را در UI روی 80 بگذار
# ───── Symfony Core ─────
APP_SECRET= # php -r "echo bin2hex(random_bytes(32));"
DEFAULT_URI=https://your-domain.com
# ── دامنه ──
APP_BASE_URL=https://your-domain.com
ALLOWED_FRONTEND_HOSTS=your-domain.com
# ───── Reverse Proxy (Coolify/Traefik) ─────
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1
# ───── Database (MariaDB/MySQL service در Coolify) ─────
DATABASE_URL="mysql://USER:PASS@HOST:3306/clinic_pro?serverVersion=mariadb-11.8.0&charset=utf8mb4"
# ───── JWT (lexik) ─────
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE= # الزامی برای تولید کلید در فاز build
# ───── CORS ─────
CORS_ALLOW_ORIGIN='^https://your-domain\.com$'
# ───── Messenger / Redis (Redis service در Coolify) ─────
MESSENGER_TRANSPORT_DSN=redis://HOST:6379/messages
REDIS_URL=redis://HOST:6379
# ── امنیتی (الزامی) ──
APP_SECRET= # php -r "echo bin2hex(random_bytes(32));"
JWT_PASSPHRASE= # openssl rand -hex 32
DB_PASSWORD=
DB_ROOT_PASSWORD=
# ───── Auth / OTP ─────
REFRESH_TOKEN_TTL=2592000
OTP_TTL=1200
# ── reverse proxy ──
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1
# ───── api.ir (استعلام هویت) ─────
# ── api.ir ──
API_IR_BASE_URL=https://s.api.ir
API_IR_TOKEN=
# ───── File Upload ─────
MAX_FILE_SIZE_BYTES=5242880
UPLOAD_DIR=var/uploads
# توجه: DATABASE_URL / MESSENGER_TRANSPORT_DSN / REDIS_URL در خودِ compose
# با نام سرویس (mariadb/redis) ساخته می‌شوند و نیازی به تعریف در UI ندارند.
# کلیدهای SMS و درگاه پرداخت از DB («تنظیمات سایت») خوانده می‌شوند، نه env.
```
**نکات مهم برای این فایل:**
- `serverVersion` در `DATABASE_URL` را با MariaDB هم‌خوان کن (`mariadb-11.8.0`)، نه `8.0`، چون پروژه روی MariaDB 11.8 است.
- توضیح بده که `HOST` در DSNها = نام internal service که Coolify برای DB/Redis می‌دهد.
- کلیدهای SMS و درگاه پرداخت از DB («تنظیمات سایت») خوانده می‌شوند، نه env — این را با کامنت ذکر کن.
### ۱۰. ساخت راهنمای `docs/deploy/coolify.md`
### ۴. ساخت راهنمای `docs/deploy/coolify.md`
یک سند گام‌به‌گام فارسی شامل:
1. **تنظیمات Coolify UI:**
- Build Pack = `nixpacks`
- Port Exposure = `80`
- Health Check Path = `/api/doc` یا یک endpoint سبک (بررسی کن چه endpoint عمومی‌ای برای healthcheck مناسب است؛ از `config/packages/security.yaml` لیست public_endpoints را ببین).
2. **Environment Variables:** ارجاع به `.env.coolify.example` + متغیرهای الزامی Nixpacks.
3. **Post-Deployment Command** (در بخش مربوطهٔ Coolify):
```
php bin/console doctrine:migrations:migrate --all-or-nothing --no-interaction
```
4. **Persistent Storage (Volumes)** — این بخش حیاتی است، حتماً توضیح بده:
- `config/jwt/` → تا کلیدهای JWT بین دیپلوی‌ها باقی بمانند (در غیر این صورت همهٔ لاگین‌ها باطل می‌شوند).
- `public/uploads/` و `var/uploads/` → فایل‌های آپلودی کاربران.
- `var/log/` (اختیاری).
5. **Worker‌ها (سرویس‌های جداگانه در Coolify):** توضیح بده که باید دو سرویس/کانتینر اضافه با همان image ولی start command متفاوت ساخته شوند، یا از Supervisor در همان کانتینر استفاده شود:
- `php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M`
- `php bin/console messenger:consume scheduler_default --time-limit=3600`
گزینهٔ توصیه‌شده را مشخص کن (در Coolify معمولاً سرویس جداگانه تمیزتر است؛ یا یک `supervisord.conf` در همان کانتینر).
6. **اولین راه‌اندازی:** ساخت admin اولیه:
```
php bin/console app:create-admin
```
### ۵. (اختیاری ولی توصیه‌شده) فایل `supervisord.conf` برای worker‌ها
اگر تصمیم گرفتی worker‌ها در همان کانتینر اجرا شوند، یک `supervisord.conf` بساز که هر دو consumer را مدیریت کند و در `nixpacks.toml` به phase setup اضافه‌اش کن. در غیر این صورت، در docs گزینهٔ «سرویس جداگانه در Coolify» را به‌عنوان روش پیش‌فرض توضیح بده و این فایل را نساز.
تصمیم پیشنهادی: **سرویس جداگانه در Coolify** (ساده‌تر، بدون نیاز به supervisor و بدون پیچیده کردن کانتینر اصلی). فقط در docs مستند کن.
سند گام‌به‌گام فارسی شامل:
1. **Coolify UI:** New Resource → Git repo → Build Pack = **Docker Compose** → مشخص کردن `docker-compose.coolify.yaml` به‌عنوان Compose file و branch.
2. **Domain:** اختصاص دامنه به سرویس `app` (پورت 80) از UI.
3. **Environment Variables:** ارجاع به `.env.coolify.example` + کدام‌ها الزامی‌اند.
4. **Persistent Storage:** توضیح volumeها (`jwt_keys`, `uploads_public`, `uploads_var`, `mariadb_data`, `redis_data`) و **اهمیت persist بودن `jwt_keys`** (وگرنه هر دیپلوی همهٔ لاگین‌ها باطل می‌شود).
5. **اولین راه‌اندازی:** بعد از اولین دیپلوی، اجرای `php bin/console app:create-admin` داخل کانتینر `app` (از Terminal کولیفای).
6. **Worker‌ها:** توضیح اینکه `worker-async` و `worker-scheduler` در همین compose بالا می‌آیند و اگر scheduler اجرا نشود نوبت‌های پرداخت‌نشده آزاد نمی‌شوند.
7. **Healthcheck:** سرویس app روی `/` یا یک endpoint عمومی (از `config/packages/security.yaml` لیست public_endpoints را بررسی کن).
## نکات مهم (محدودیت‌ها و edge caseها)
- **MariaDB نه PostgreSQL:** داک رسمی Coolify مثال PostgreSQL می‌زند؛ این پروژه MySQL/MariaDB است. همه‌جا DSN را `mysql://...` با `serverVersion=mariadb-11.8.0` نگه دار.
- **build فرانت‌اند الزامی است:** بدون `yarn build`، صفحات admin (و دارایی‌های React) لود نمی‌شوند چون `public/build/` خالی می‌ماند. این تفاوت اصلی با setup پیش‌فرض Symfony در داک Coolify است.
- **JWT keypair persistence:** اگر volume برای `config/jwt/` تعریف نشود، هر دیپلوی کلید جدید می‌سازد و کاربران logout می‌شوند. این مهم‌ترین نکتهٔ عملیاتی است — حتماً در docs برجسته شود.
- **`JWT_PASSPHRASE` باید قبل از build در env باشد** وگرنه `lexik:jwt:generate-keypair` در فاز build شکست می‌خورد.
- **Scheduler:** transport `scheduler_default` نوبت‌های پرداخت‌نشده را هر دقیقه منقضی می‌کند؛ اگر worker آن اجرا نشود، نوبت‌های رزرو ولی پرداخت‌نشده آزاد نمی‌شوند. این رفتار را در docs ذکر کن.
- **هیچ منطق برنامه‌ای را تغییر نده** — فقط فایل‌های infra/config اضافه یا ویرایش شوند.
- **بدون تغییر در ddev:** فایل‌های `compose.yaml` و `.ddev/` دست‌نخورده بمانند تا محیط لوکال نشکند.
- بعد از تغییر `framework.yaml`، `ddev exec php bin/console cache:clear` بزن تا مطمئن شوی config معتبر است.
- این تغییرات API را عوض نمی‌کنند، پس نیازی به به‌روزرسانی `docs/api/*` نیست؛ ولی فایل جدید `docs/deploy/coolify.md` اضافه می‌شود.
- **MariaDB نه PostgreSQL:** `compose.yaml` موجود (postgres) فقط ddev است و نامرتبط؛ دست نزن. compose جدید MariaDB 11.8 با DSN `mysql://...&serverVersion=mariadb-11.8.0`.
- **بدون شبکهٔ سفارشی در compose** — هشدار صریح داک Coolify.
- **build فرانت‌اند داخل Dockerfile** — بدون stage assets و `yarn build`، پنل admin لود نمی‌شود.
- **persist کلید JWT** روی volume `jwt_keys` مهم‌ترین نکتهٔ عملیاتی است.
- **`JWT_PASSPHRASE` و `APP_SECRET`** باید قبل از اولین استارت در env کولیفای باشند.
- **race در migration:** فقط سرویس `app` با `RUN_INIT=1`؛ worker‌ها `RUN_INIT=0`.
- **ddev دست‌نخورده:** `compose.yaml`، `compose.override.yaml`، `.ddev/` تغییر نکنند.
- **هیچ منطق برنامه‌ای تغییر نکند** — فقط فایل‌های infra + یک ویرایش `framework.yaml`.
- بعد از تغییر `framework.yaml`، `ddev exec php bin/console cache:clear` بزن تا اعتبار config تأیید شود.
- این تغییرات API را عوض نمی‌کنند → نیازی به به‌روزرسانی `docs/api/*` نیست؛ فقط `docs/deploy/coolify.md` اضافه می‌شود.
- **`.dockerignore`** حتماً `vendor/`, `node_modules/`, `var/` را حذف کند تا build context سبک بماند و artifactهای لوکال وارد image نشوند.
+40
View File
@@ -0,0 +1,40 @@
# Build artifacts & dependencies (rebuilt inside the image)
/node_modules
/vendor
/var
/public/build
/public/bundles
/public/uploads
# VCS & local tooling
/.git
/.gitignore
/.ddev
/.claude
/.agents
/.vscode
/.idea
/.editorconfig
# Tests & static analysis
/tests
/phpunit.dist.xml
/.phpunit.cache
/phpstan.neon
# Local env & secrets
.env.local
.env.*.local
/config/jwt/*.pem
# Database dumps & seed data
*.sql
*.sql.gz
# Docs (not needed at runtime)
/docs
/graphify-out
# OS noise
.DS_Store
*.log
+46
View File
@@ -0,0 +1,46 @@
# ============================================================
# Coolify Environment Variables — ClinicPro (Docker Compose)
# ------------------------------------------------------------
# Copy these into the Coolify resource's "Environment Variables" tab.
# Only the variables referenced as ${...} in docker-compose.coolify.yaml
# need to be set here. DATABASE_URL / MESSENGER_TRANSPORT_DSN / REDIS_URL
# are built INSIDE the compose file from the mariadb/redis service names.
# ============================================================
# ── Backend's own domain (single URL — used for payment callbacks & absolute URLs) ──
# This is the API host, NOT a frontend domain.
APP_BASE_URL=https://api.nobat724.com
# ── Frontend domains (MANY) ──
# Both values below are GENERATED from docker/frontend-domains.json.
# To add/remove a city domain: edit that file, then run:
# ddev exec php docker/gen-cors-env.php (or: php docker/gen-cors-env.php on the server)
# and paste the new output here / into Coolify.
ALLOWED_FRONTEND_HOSTS=ahvaz-nobat.ir,arak-nobat.ir,ardabil-nobat.ir,bandar-nobat.ir,behbahan-nobat.ir,birjand-nobat.ir,bojnord-nobat.ir,bushehr-nobat.ir,dehdasht-nobat.ir,esf-nobat.ir,golestan-nobat.ir,hamadan-nobat.ir,ilam-nobat.ir,karaj-nobat.ir,kerman-nobat.ir,kermanshah-nobat.ir,lorestan-nobat.ir,mashhad-nobat.ir,nobat724.com,qazvin-nobat.ir,qom-nobat.ir,rasht-nobat.ir,sanandaj-nobat.ir,sari-nobat.ir,semnan-nobat.ir,shiraz-nobat.ir,shkord-nobat.ir,tabriz-nobat.ir,tehran-nobat.ir,urmia-nobat.ir,yasuj-nobat.ir,yazd-nobat.ir,zahedan-nobat.ir,zanjan-nobat.ir
CORS_ALLOW_ORIGIN='^https://(ahvaz\-nobat\.ir|arak\-nobat\.ir|ardabil\-nobat\.ir|bandar\-nobat\.ir|behbahan\-nobat\.ir|birjand\-nobat\.ir|bojnord\-nobat\.ir|bushehr\-nobat\.ir|dehdasht\-nobat\.ir|esf\-nobat\.ir|golestan\-nobat\.ir|hamadan\-nobat\.ir|ilam\-nobat\.ir|karaj\-nobat\.ir|kerman\-nobat\.ir|kermanshah\-nobat\.ir|lorestan\-nobat\.ir|mashhad\-nobat\.ir|nobat724\.com|qazvin\-nobat\.ir|qom\-nobat\.ir|rasht\-nobat\.ir|sanandaj\-nobat\.ir|sari\-nobat\.ir|semnan\-nobat\.ir|shiraz\-nobat\.ir|shkord\-nobat\.ir|tabriz\-nobat\.ir|tehran\-nobat\.ir|urmia\-nobat\.ir|yasuj\-nobat\.ir|yazd\-nobat\.ir|zahedan\-nobat\.ir|zanjan\-nobat\.ir)$'
# ── Secrets (REQUIRED — set before the first deploy) ──
APP_SECRET= # php -r "echo bin2hex(random_bytes(32));"
JWT_PASSPHRASE= # openssl rand -hex 32 (must exist before first start: JWT keypair is generated with it)
DB_PASSWORD= # application DB user password
DB_ROOT_PASSWORD= # MariaDB root password
# Tip: in Coolify you may use magic vars instead of hardcoding, e.g.
# DB_PASSWORD=${SERVICE_PASSWORD_DB}
# APP_SECRET=${SERVICE_HEX_APPSECRET}
# ── Reverse proxy (Coolify/Traefik) ──
# Docker internal network ranges so Symfony trusts X-Forwarded-* headers.
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1
# ── api.ir identity inquiry (Shahkar + IbanMatch) ──
# Empty token => fail-closed (representative verification is rejected).
API_IR_BASE_URL=https://s.api.ir
API_IR_TOKEN=
# ── Notes ──
# • SMS keys (kavenegar/rangineh) and payment gateway keys (mellat/sep) are read
# from the DB ("Site Settings"), NOT from env. No need to set them here.
# • REFRESH_TOKEN_TTL / OTP_TTL / MAX_FILE_SIZE_BYTES are fixed in the compose file.
+63
View File
@@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1
# ============================================================
# Stage 1 — PHP dependencies (Composer)
# ============================================================
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
# --no-scripts: the Symfony kernel isn't fully copied yet; scripts run later in entrypoint.
RUN composer install \
--no-dev --no-scripts --no-interaction \
--prefer-dist --optimize-autoloader \
--ignore-platform-reqs
# ============================================================
# Stage 2 — Frontend assets (Webpack Encore / React 19)
# ============================================================
FROM node:20-alpine AS assets
WORKDIR /app
# vendor is required: package.json references "@symfony/ux-react": "file:vendor/symfony/ux-react/assets"
COPY --from=vendor /app/vendor ./vendor
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY webpack.config.js postcss.config.js tsconfig.json ./
COPY assets ./assets
COPY public ./public
RUN yarn build # outputs to public/build
# ============================================================
# Stage 3 — Runtime (PHP-FPM + Nginx via Supervisor)
# ============================================================
FROM php:8.2-fpm-alpine AS runtime
# System deps + PHP extensions.
# pdo_mysql -> MariaDB, intl -> Symfony, opcache -> perf, redis -> Messenger/cache transport
RUN apk add --no-cache nginx supervisor icu-libs \
&& apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev \
&& docker-php-ext-install pdo_mysql intl opcache \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del .build-deps
WORKDIR /app
# Application source.
COPY . .
# Built dependencies from earlier stages (overwrite anything from the source copy).
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
# Container configuration.
COPY docker/php/php.ini /usr/local/etc/php/conf.d/zz-app.ini
COPY docker/nginx/default.conf /etc/nginx/http.d/default.conf
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh \
&& mkdir -p var/cache var/log var/uploads public/uploads config/jwt \
&& chown -R www-data:www-data var public/uploads config/jwt
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
+5
View File
@@ -5,6 +5,11 @@ framework:
# Note that the session will be started ONLY if you read or write from it.
session: true
# Behind Coolify/Traefik reverse proxy. TRUSTED_PROXIES is empty by default
# (local/ddev) and set to the Docker network range in production.
trusted_proxies: '%env(default::TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']
#esi: true
#fragments: true
+121
View File
@@ -0,0 +1,121 @@
# Coolify deployment — Build Pack: Docker Compose
# Select THIS file as the Compose file in the Coolify resource settings.
# Do NOT define custom `networks:` — Coolify manages the network; custom ones
# break Traefik routing (per Coolify docs).
# Shared application environment, reused by app + both workers.
x-app-env: &app-env
APP_ENV: prod
APP_DEBUG: "0"
APP_SECRET: ${APP_SECRET}
DATABASE_URL: "mysql://clinic:${DB_PASSWORD}@mariadb:3306/clinic_pro?serverVersion=mariadb-11.8.0&charset=utf8mb4"
MESSENGER_TRANSPORT_DSN: redis://redis:6379/messages
REDIS_URL: redis://redis:6379
JWT_SECRET_KEY: "%kernel.project_dir%/config/jwt/private.pem"
JWT_PUBLIC_KEY: "%kernel.project_dir%/config/jwt/public.pem"
JWT_PASSPHRASE: ${JWT_PASSPHRASE}
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN}
DEFAULT_URI: ${APP_BASE_URL}
APP_BASE_URL: ${APP_BASE_URL}
ALLOWED_FRONTEND_HOSTS: ${ALLOWED_FRONTEND_HOSTS}
TRUSTED_PROXIES: ${TRUSTED_PROXIES}
API_IR_BASE_URL: ${API_IR_BASE_URL}
API_IR_TOKEN: ${API_IR_TOKEN}
REFRESH_TOKEN_TTL: "2592000"
OTP_TTL: "1200"
MAX_FILE_SIZE_BYTES: "5242880"
UPLOAD_DIR: var/uploads
# Shared volume mounts for services that read/write JWT keys and uploads.
x-app-volumes: &app-volumes
- jwt_keys:/app/config/jwt
- uploads_public:/app/public/uploads
- uploads_var:/app/var/uploads
# Shared dependency gate on DB + Redis being up.
x-app-depends: &app-depends
mariadb:
condition: service_healthy
redis:
condition: service_started
services:
# Web app (PHP-FPM + Nginx).
# In Coolify, assign ALL serving domains to THIS service (port 80) — the backend
# API host plus every frontend domain you want Traefik to route + issue TLS for.
# Coolify supports a comma-separated domain list on the service.
# CORS/payload allow-listing for those frontends is handled separately via
# CORS_ALLOW_ORIGIN / ALLOWED_FRONTEND_HOSTS (see docker/frontend-domains.json).
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
environment:
<<: *app-env
RUN_INIT: "1" # runs JWT keygen + migrations on start (only this service)
volumes: *app-volumes
depends_on: *app-depends
healthcheck:
test: ["CMD", "php", "-r", "exit(@fsockopen('127.0.0.1', 80) ? 0 : 1);"]
interval: 15s
timeout: 5s
retries: 5
start_period: 60s
# SMS / async message consumer.
worker-async:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
command: php bin/console messenger:consume async --time-limit=3600 --memory-limit=128M -v
environment:
<<: *app-env
RUN_INIT: "0"
volumes: *app-volumes
depends_on: *app-depends
# Scheduler consumer — expires unpaid appointments every minute.
worker-scheduler:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
command: php bin/console messenger:consume scheduler_default --time-limit=3600 -v
environment:
<<: *app-env
RUN_INIT: "0"
volumes: *app-volumes
depends_on: *app-depends
mariadb:
image: mariadb:11.8
restart: unless-stopped
environment:
MARIADB_DATABASE: clinic_pro
MARIADB_USER: clinic
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis_data:/data
volumes:
jwt_keys:
uploads_public:
uploads_var:
mariadb_data:
redis_data:
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
set -e
# Volume-mounted dirs (jwt_keys, uploads) are created/owned by root on first run.
# Ensure the runtime user can write to them. Runs every start; cheap and idempotent.
chown -R www-data:www-data var public/uploads config/jwt 2>/dev/null || true
# One-time init tasks — only the web service runs these (RUN_INIT=1).
# Workers set RUN_INIT=0 so DB migrations / JWT generation don't race.
if [ "${RUN_INIT:-1}" = "1" ]; then
# Generate JWT keypair if not already persisted on the jwt_keys volume.
php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction
# Rebuild the prod cache (vendor was installed with --no-scripts at build time).
php bin/console cache:clear --no-warmup
php bin/console cache:warmup
# Apply pending migrations. --all-or-nothing wraps them in a transaction.
php bin/console doctrine:migrations:migrate --all-or-nothing --no-interaction
fi
exec "$@"
+39
View File
@@ -0,0 +1,39 @@
{
"_comment": "Frontend domains served by this backend. Add an entry per city, then run: ddev exec php docker/gen-cors-env.php — paste the output into Coolify env (CORS_ALLOW_ORIGIN + ALLOWED_FRONTEND_HOSTS), add the same domain to the app service in Coolify UI, and redeploy.",
"domains": [
{ "domain": "nobat724.com", "label": "اصلی" },
{ "domain": "ahvaz-nobat.ir", "label": "اهواز" },
{ "domain": "arak-nobat.ir", "label": "اراک" },
{ "domain": "ardabil-nobat.ir", "label": "اردبیل" },
{ "domain": "bandar-nobat.ir", "label": "بندرعباس" },
{ "domain": "behbahan-nobat.ir", "label": "بهبهان" },
{ "domain": "birjand-nobat.ir", "label": "بیرجند" },
{ "domain": "bojnord-nobat.ir", "label": "بجنورد" },
{ "domain": "bushehr-nobat.ir", "label": "بوشهر" },
{ "domain": "dehdasht-nobat.ir", "label": "دهدشت" },
{ "domain": "esf-nobat.ir", "label": "اصفهان" },
{ "domain": "golestan-nobat.ir", "label": "گلستان" },
{ "domain": "hamadan-nobat.ir", "label": "همدان" },
{ "domain": "ilam-nobat.ir", "label": "ایلام" },
{ "domain": "karaj-nobat.ir", "label": "کرج" },
{ "domain": "kerman-nobat.ir", "label": "کرمان" },
{ "domain": "kermanshah-nobat.ir", "label": "کرمانشاه" },
{ "domain": "lorestan-nobat.ir", "label": "لرستان" },
{ "domain": "mashhad-nobat.ir", "label": "مشهد" },
{ "domain": "qazvin-nobat.ir", "label": "قزوین" },
{ "domain": "qom-nobat.ir", "label": "قم" },
{ "domain": "rasht-nobat.ir", "label": "رشت" },
{ "domain": "sanandaj-nobat.ir", "label": "سنندج" },
{ "domain": "sari-nobat.ir", "label": "ساری" },
{ "domain": "semnan-nobat.ir", "label": "سمنان" },
{ "domain": "shiraz-nobat.ir", "label": "شیراز" },
{ "domain": "shkord-nobat.ir", "label": "شهرکرد" },
{ "domain": "tabriz-nobat.ir", "label": "تبریز" },
{ "domain": "tehran-nobat.ir", "label": "تهران" },
{ "domain": "urmia-nobat.ir", "label": "ارومیه" },
{ "domain": "yasuj-nobat.ir", "label": "یاسوج" },
{ "domain": "yazd-nobat.ir", "label": "یزد" },
{ "domain": "zahedan-nobat.ir", "label": "زاهدان" },
{ "domain": "zanjan-nobat.ir", "label": "زنجان" }
]
}
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env php
<?php
/**
* Generates the multi-domain env values for Coolify from docker/frontend-domains.json.
*
* Usage:
* php docker/gen-cors-env.php
*
* Output: CORS_ALLOW_ORIGIN (single regex, explicit alternation) and
* ALLOWED_FRONTEND_HOSTS (comma-separated host list).
* Paste both into the Coolify Environment Variables tab.
*/
$jsonFile = __DIR__ . '/frontend-domains.json';
if (!is_file($jsonFile)) {
fwrite(STDERR, "Missing $jsonFile\n");
exit(1);
}
try {
$data = json_decode(file_get_contents($jsonFile), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
fwrite(STDERR, "Invalid JSON in $jsonFile: {$e->getMessage()}\n");
exit(1);
}
if (!isset($data['domains']) || !is_array($data['domains'])) {
fwrite(STDERR, "Expected a \"domains\" array in $jsonFile\n");
exit(1);
}
$domains = [];
foreach ($data['domains'] as $entry) {
$host = is_array($entry) ? ($entry['domain'] ?? null) : $entry;
$host = is_string($host) ? trim($host) : '';
if ($host === '') {
continue;
}
if (!preg_match('/^[a-z0-9.-]+$/i', $host)) {
fwrite(STDERR, "Skipping invalid domain: \"$host\"\n");
continue;
}
$domains[strtolower($host)] = true; // dedupe, case-insensitive
}
$domains = array_keys($domains);
sort($domains);
if (empty($domains)) {
fwrite(STDERR, "No valid domains found in $jsonFile\n");
exit(1);
}
// CORS: explicit alternation, anchored, dots escaped. https only.
$alternation = implode('|', array_map(static fn (string $d): string => preg_quote($d, '/'), $domains));
$cors = "^https://($alternation)$";
// Frontend hosts: bare hostnames, comma-separated (matched via in_array in PaymentController).
$hosts = implode(',', $domains);
echo "# ---- paste into Coolify env (" . count($domains) . " domains) ----\n\n";
echo "CORS_ALLOW_ORIGIN='" . $cors . "'\n\n";
echo "ALLOWED_FRONTEND_HOSTS=" . $hosts . "\n";
+30
View File
@@ -0,0 +1,30 @@
server {
listen 80 default_server;
server_name _;
root /app/public;
# Symfony front controller
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass 127.0.0.1:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
# Forwarded headers from Traefik (Coolify) are trusted via Symfony trusted_proxies
internal;
}
# Block direct access to any other .php file
location ~ \.php$ {
return 404;
}
client_max_body_size 16m; # keep in sync with MAX_FILE_SIZE_BYTES / php.ini
error_log /dev/stderr warn;
access_log /dev/stdout;
}
+21
View File
@@ -0,0 +1,21 @@
; Production PHP settings for ClinicPro on Coolify
memory_limit = 256M
upload_max_filesize = 16M
post_max_size = 16M
max_execution_time = 60
expose_php = Off
date.timezone = Asia/Tehran
; OPcache (production)
opcache.enable = 1
opcache.enable_cli = 0
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.interned_strings_buffer = 16
opcache.preload = /app/config/preload.php
opcache.preload_user = www-data
; Realpath cache (perf)
realpath_cache_size = 4096k
realpath_cache_ttl = 600
+24
View File
@@ -0,0 +1,24 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/stdout
logfile_maxbytes=0
pidfile=/run/supervisord.pid
[program:php-fpm]
command=php-fpm -F
autorestart=true
priority=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nginx]
command=nginx -g 'daemon off;'
autorestart=true
priority=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
+136
View File
@@ -0,0 +1,136 @@
# دیپلوی ClinicPro روی Coolify (Docker Compose)
این راهنما نحوهٔ دیپلوی بک‌اند ClinicPro (Symfony 7.4 + پنل React) را روی **Coolify** با Build Pack از نوع **Docker Compose** توضیح می‌دهد.
> همهٔ فایل‌های دیپلوی در ریشهٔ ریپو هستند: `Dockerfile`، `docker-compose.coolify.yaml`، پوشهٔ `docker/` و `.env.coolify.example`.
> فایل‌های `compose.yaml`، `compose.override.yaml` و `.ddev/` مخصوص محیط لوکال (ddev) هستند و **در دیپلوی نقشی ندارند**.
---
## معماری دیپلوی
`docker-compose.coolify.yaml` پنج سرویس بالا می‌آورد:
| سرویس | نقش | نکته |
|-------|-----|------|
| `app` | وب (PHP-FPM + Nginx) | دامنه به این سرویس اختصاص می‌یابد (پورت ۸۰). `RUN_INIT=1` → migration و تولید کلید JWT |
| `worker-async` | مصرف صف `async` (ارسال SMS) | `RUN_INIT=0` |
| `worker-scheduler` | مصرف `scheduler_default` (انقضای نوبت‌های پرداخت‌نشده، هر دقیقه) | `RUN_INIT=0` |
| `mariadb` | دیتابیس MariaDB 11.8 | healthcheck دارد؛ سرویس‌های اپ منتظر سالم‌شدن آن می‌مانند |
| `redis` | Messenger transport + کش/OTP | با appendonly persist می‌شود |
هر سه سرویس اپ از یک image یکسان (همان `Dockerfile`) ساخته می‌شوند و فقط `command`/`RUN_INIT` آن‌ها متفاوت است.
---
## مراحل دیپلوی
### ۱. ساخت Resource در Coolify
1. **New Resource → Public/Private Repository** و ریپوی `clinicpro` را انتخاب کن.
2. **Build Pack** را روی **Docker Compose** بگذار (نه Nixpacks).
3. در تنظیمات:
- **Branch:** `main`
- **Base Directory:** `/` (ریشهٔ ریپو)
- **Docker Compose File:** `docker-compose.coolify.yaml`
### ۲. اختصاص دامنه
- در سرویس `app`، **همهٔ** دامنه‌هایی که باید سرویس بگیرند را وارد کن — هم دامنهٔ API بک‌اند و هم همهٔ دامنه‌های فرانت‌اند (`nobat724.com` و همهٔ `*-nobat.ir`). Coolify لیست دامنهٔ کامادار را روی یک سرویس می‌پذیرد.
- چون کانتینر روی پورت `80` گوش می‌دهد، نیازی به افزودن پورت به دامنه نیست.
- Coolify به‌صورت خودکار برای هر دامنه TLS را از طریق Traefik (Let's Encrypt) صادر می‌کند.
> دو مفهوم را اشتباه نگیر:
> - **اختصاص دامنه در UI** = Traefik برای آن دامنه روت و گواهی TLS می‌سازد.
> - **`CORS_ALLOW_ORIGIN` / `ALLOWED_FRONTEND_HOSTS`** = سیمفونی به آن origin اجازهٔ مرورگری/پرداخت می‌دهد.
>
> یک دامنهٔ جدید معمولاً به **هر دو** نیاز دارد: هم در UI کولیفای اضافه شود، هم در `docker/frontend-domains.json` (و سپس بازتولید env). به بخش «چند دامنه فرانت‌اند» پایین مراجعه کن.
### ۳. متغیرهای محیطی
محتوای [`.env.coolify.example`](../../.env.coolify.example) را در تب **Environment Variables** وارد کن. الزامی‌ها پیش از اولین دیپلوی:
| متغیر | توضیح |
|-------|-------|
| `APP_SECRET` | `php -r "echo bin2hex(random_bytes(32));"` |
| `JWT_PASSPHRASE` | `openssl rand -hex 32`**باید قبل از اولین استارت موجود باشد** (کلید JWT با آن ساخته می‌شود) |
| `DB_PASSWORD` | پسورد یوزر دیتابیس |
| `DB_ROOT_PASSWORD` | پسورد root مریادی‌بی |
| `APP_BASE_URL` | دامنهٔ **خودِ بک‌اند** (مثلاً `https://api.nobat724.com`) — برای callback پرداخت و URL مطلق |
| `ALLOWED_FRONTEND_HOSTS` / `CORS_ALLOW_ORIGIN` | دامنه‌های **فرانت‌اند** (چندتایی) — به بخش «چند دامنه» پایین مراجعه کن |
| `TRUSTED_PROXIES` | پیش‌فرض رنج شبکهٔ داخلی داکر (در فایل نمونه هست) |
| `API_IR_TOKEN` | توکن استعلام هویت (خالی = استعلام رد می‌شود) |
> `DATABASE_URL`، `MESSENGER_TRANSPORT_DSN` و `REDIS_URL` در خودِ compose از نام سرویس‌ها (`mariadb`/`redis`) ساخته می‌شوند؛ در UI تعریف نکن.
> کلیدهای **SMS** و **درگاه پرداخت** از DB («تنظیمات سایت») خوانده می‌شوند، نه از env.
> می‌توانی به‌جای hardcode از magic variableهای Coolify استفاده کنی، مثلاً `DB_PASSWORD=${SERVICE_PASSWORD_DB}`.
#### چند دامنه فرانت‌اند (مهم)
این بک‌اند به ده‌ها دامنهٔ شهری سرویس می‌دهد (`nobat724.com` و `*-nobat.ir`). دو متغیر باید همهٔ این دامنه‌ها را پوشش دهند:
- **`ALLOWED_FRONTEND_HOSTS`** — لیست host با کاما؛ در validate کردن host بازگشتِ پرداخت استفاده می‌شود (تطبیق دقیق در `PaymentController::isAllowedFrontend`).
- **`CORS_ALLOW_ORIGIN`** — یک regex واحد (nelmio با `origin_regex: true`) که فقط `https` و دقیقاً همان host‌ها را می‌پذیرد.
هر دو مقدار به‌صورت **خودکار** از فایل [`docker/frontend-domains.json`](../../docker/frontend-domains.json) تولید می‌شوند. برای افزودن یا حذف یک دامنه:
```bash
# ۱) یک رکورد به آرایهٔ "domains" در docker/frontend-domains.json اضافه/حذف کن، مثلاً:
# { "domain": "newcity-nobat.ir", "label": "شهر جدید" }
# ۲) مقادیر جدید را تولید کن:
ddev exec php docker/gen-cors-env.php # لوکال
# یا روی سرور داخل کانتینر app:
php docker/gen-cors-env.php
# ۳) خروجی (CORS_ALLOW_ORIGIN و ALLOWED_FRONTEND_HOSTS) را در Coolify جایگزین کن و دوباره deploy کن
```
> فیلد `label` فقط برای خوانایی است و در تولید env استفاده نمی‌شود؛ فقط `domain` مهم است.
> `payment_allowed_frontend_hosts` در «تنظیمات سایت» (DB) بر مقدار env اولویت دارد؛ اگر آن را در DB ست کرده‌ای، آن مرجع است.
### ۴. Persistent Storage (حیاتی)
این volumeها در compose تعریف شده‌اند و Coolify آن‌ها را persist می‌کند:
| Volume | مسیر | چرا مهم است |
|--------|------|-------------|
| `jwt_keys` | `/app/config/jwt` | **مهم‌ترین.** کلید JWT بین دیپلوی‌ها باید ثابت بماند؛ در غیر این صورت هر دیپلوی همهٔ توکن‌ها را باطل و همهٔ کاربران را logout می‌کند |
| `uploads_public` | `/app/public/uploads` | فایل‌های آپلودی عمومی |
| `uploads_var` | `/app/var/uploads` | فایل‌های آپلودی خصوصی |
| `mariadb_data` | `/var/lib/mysql` | دادهٔ دیتابیس |
| `redis_data` | `/data` | پایداری Redis |
> مطمئن شو در Coolify این volumeها به‌صورت **named volume** (نه ephemeral) باقی می‌مانند.
### ۵. اولین دیپلوی و ساخت ادمین
1. **Deploy** را بزن. سرویس `app` هنگام استارت به‌صورت خودکار:
- کلید JWT می‌سازد (اگر روی volume نباشد)،
- کش prod را warm می‌کند،
- migrationها را با `--all-or-nothing` اجرا می‌کند.
2. بعد از سالم‌شدن سرویس‌ها، از **Terminal** سرویس `app` در Coolify، ادمین اولیه را بساز:
```bash
php bin/console app:create-admin
```
---
## نکات عملیاتی
- **Worker‌ها:** اگر `worker-scheduler` بالا نباشد، نوبت‌های رزرو ولی پرداخت‌نشده **آزاد نمی‌شوند**. اگر `worker-async` بالا نباشد، **SMS ارسال نمی‌شود**. هر دو در همین compose مدیریت می‌شوند و با `restart: unless-stopped` خودکار بازمی‌گردند.
- **Migration در دیپلوی‌های بعدی:** فقط سرویس `app` (با `RUN_INIT=1`) migration اجرا می‌کند تا بین سرویس‌ها race رخ ندهد. هر دیپلوی، migrationهای جدید را اعمال می‌کند.
- **Health check:** سرویس `app` با یک fsockopen روی پورت 80 سالم‌بودن خود را گزارش می‌دهد.
- **Trusted Proxies:** مقدار `TRUSTED_PROXIES` به Symfony می‌گوید به هدرهای `X-Forwarded-*` از Traefik اعتماد کند تا `https` و IP واقعی کلاینت درست تشخیص داده شوند. در محیط لوکال (ddev) این متغیر تنظیم نمی‌شود و مقدار پیش‌فرض خالی است.
- **بدون شبکهٔ سفارشی:** طبق توصیهٔ Coolify، در compose هیچ `networks:` سفارشی تعریف نشده تا روتینگ Traefik پایدار بماند.
---
## رفع اشکال
| نشانه | علت محتمل | راه‌حل |
|-------|-----------|--------|
| همهٔ کاربران بعد از دیپلوی logout می‌شوند | volume `jwt_keys` persist نشده | بررسی named volume بودن آن |
| خطای اتصال به دیتابیس هنگام استارت | `app` قبل از سالم‌شدن `mariadb` بالا آمده | `depends_on: condition: service_healthy` این را پوشش می‌دهد؛ صبر کن یا لاگ `mariadb` را ببین |
| پنل admin سفید/بدون استایل | دارایی‌های `public/build` ساخته نشده | بررسی موفقیت stage `assets` در لاگ build (`yarn build`) |
| تولید کلید JWT شکست می‌خورد | `JWT_PASSPHRASE` تنظیم نشده | متغیر را در Coolify ست کن و دوباره deploy کن |
| تصاویر/فایل‌های آپلودی بعد از ری‌دیپلوی ناپدید می‌شوند | volumeهای uploads persist نشده | بررسی `uploads_public` / `uploads_var` |