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
+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