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>
This commit is contained in:
+20
-3
@@ -85,19 +85,36 @@ RUN [ -f .env ] || printf 'APP_ENV=prod\nAPP_DEBUG=0\n' > .env
|
|||||||
|
|
||||||
# Container configuration.
|
# Container configuration.
|
||||||
COPY docker/php/php.ini /usr/local/etc/php/conf.d/zz-app.ini
|
COPY docker/php/php.ini /usr/local/etc/php/conf.d/zz-app.ini
|
||||||
|
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/zz-opcache.ini
|
||||||
COPY docker/php/zz-pool.conf /usr/local/etc/php-fpm.d/zz-pool.conf
|
COPY docker/php/zz-pool.conf /usr/local/etc/php-fpm.d/zz-pool.conf
|
||||||
COPY docker/nginx/default.conf /etc/nginx/http.d/default.conf
|
COPY docker/nginx/default.conf /etc/nginx/http.d/default.conf
|
||||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh \
|
COPY docker/healthcheck.sh /usr/local/bin/healthcheck.sh
|
||||||
|
|
||||||
|
# Make nginx run as the non-root www-data user:
|
||||||
|
# - drop the `user nginx;` directive (ignored + warns when master isn't root)
|
||||||
|
# - put the pid file in a www-data-writable path (default /run/nginx is root-only)
|
||||||
|
RUN sed -i '/^user /d' /etc/nginx/nginx.conf \
|
||||||
|
&& sed -i '1i pid /tmp/nginx.pid;' /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/healthcheck.sh \
|
||||||
&& mkdir -p var/cache var/log var/uploads public/uploads config/jwt \
|
&& mkdir -p var/cache var/log var/uploads public/uploads config/jwt \
|
||||||
# Source may be copied with restrictive (0600) host perms; ensure the runtime
|
# Source may be copied with restrictive (0600) host perms; ensure the runtime
|
||||||
# user (www-data) can read all app files — opcache preload runs as www-data
|
# user (www-data) can read all app files — opcache preload runs as www-data
|
||||||
# and otherwise fails with "Permission denied" on /app/config/preload.php.
|
# and otherwise fails with "Permission denied" on /app/config/preload.php.
|
||||||
&& chmod -R a+rX /app \
|
&& chmod -R a+rX /app \
|
||||||
&& chown -R www-data:www-data var public/uploads config/jwt
|
&& chown -R www-data:www-data var public/uploads config/jwt \
|
||||||
|
# nginx (run as www-data) needs to write its temp/cache/log dirs.
|
||||||
|
&& chown -R www-data:www-data /var/lib/nginx /var/log/nginx 2>/dev/null || true
|
||||||
|
|
||||||
EXPOSE 80
|
# Drop privileges: the entrypoint, supervisord, php-fpm and nginx all run as
|
||||||
|
# www-data. nginx binds 8080 (non-privileged) so root is never required.
|
||||||
|
USER www-data
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --retries=5 --start-period=60s \
|
||||||
|
CMD ["/usr/local/bin/healthcheck.sh"]
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|||||||
+6
-4
@@ -64,11 +64,9 @@ services:
|
|||||||
RUN_INIT: "1" # runs JWT keygen + migrations on start (only this service)
|
RUN_INIT: "1" # runs JWT keygen + migrations on start (only this service)
|
||||||
volumes: *app-volumes
|
volumes: *app-volumes
|
||||||
networks: [coolify]
|
networks: [coolify]
|
||||||
# Hit the real /health route through nginx+fpm — verifies the app actually
|
# Hit the real /health route (nginx on 8080, non-root) via docker/healthcheck.sh.
|
||||||
# boots and serves (not just that port 80 is open). Uses PHP (always present)
|
|
||||||
# so we don't depend on curl/wget being in the image; fails on non-2xx.
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "php", "-r", "$h=@get_headers('http://127.0.0.1:80/health'); exit($h && strpos($h[0],'200')!==false ? 0 : 1);"]
|
test: ["CMD", "/usr/local/bin/healthcheck.sh"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -85,6 +83,9 @@ services:
|
|||||||
volumes: *app-volumes
|
volumes: *app-volumes
|
||||||
networks: [coolify]
|
networks: [coolify]
|
||||||
depends_on: [app]
|
depends_on: [app]
|
||||||
|
# Messenger consumers handle SIGTERM gracefully — give the in-flight message
|
||||||
|
# time to finish before the container is killed.
|
||||||
|
stop_grace_period: 30s
|
||||||
# No port to probe — just confirm the consumer process is alive (busybox ps).
|
# No port to probe — just confirm the consumer process is alive (busybox ps).
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "ps -o args 2>/dev/null | grep -q '[m]essenger:consume async' || exit 1"]
|
test: ["CMD-SHELL", "ps -o args 2>/dev/null | grep -q '[m]essenger:consume async' || exit 1"]
|
||||||
@@ -104,6 +105,7 @@ services:
|
|||||||
volumes: *app-volumes
|
volumes: *app-volumes
|
||||||
networks: [coolify]
|
networks: [coolify]
|
||||||
depends_on: [app]
|
depends_on: [app]
|
||||||
|
stop_grace_period: 30s
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "ps -o args 2>/dev/null | grep -q '[m]essenger:consume scheduler_default' || exit 1"]
|
test: ["CMD-SHELL", "ps -o args 2>/dev/null | grep -q '[m]essenger:consume scheduler_default' || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Container healthcheck — hits the real Symfony /health route through nginx+fpm,
|
||||||
|
# so a green status means the app actually boots and serves (not just that the
|
||||||
|
# port is open). Uses PHP (always present in the image) to avoid depending on
|
||||||
|
# curl/wget. nginx listens on 8080 (non-root). Exit 0 = healthy, 1 = unhealthy.
|
||||||
|
exec php -r '$h=@get_headers("http://127.0.0.1:8080/health"); exit($h && strpos($h[0]," 200")!==false ? 0 : 1);'
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
server {
|
server {
|
||||||
listen 80 default_server;
|
# Non-privileged port so the whole stack can run as www-data (non-root).
|
||||||
|
# Coolify/Traefik routes to whatever port the service exposes — assign 8080
|
||||||
|
# as the service port in Coolify.
|
||||||
|
listen 8080 default_server;
|
||||||
server_name _;
|
server_name _;
|
||||||
root /app/public;
|
root /app/public;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
; OPcache — production tuning for ClinicPro.
|
||||||
|
; validate_timestamps=0: code never changes at runtime in an immutable image, so
|
||||||
|
; skip stat() checks for max throughput. (A new deploy = a new image.)
|
||||||
|
; preload: warms the Symfony container/classes into shared memory at FPM start;
|
||||||
|
; runs as www-data, so app files must be readable by www-data (see Dockerfile).
|
||||||
|
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
|
||||||
@@ -6,16 +6,6 @@ max_execution_time = 60
|
|||||||
expose_php = Off
|
expose_php = Off
|
||||||
date.timezone = Asia/Tehran
|
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 (perf)
|
||||||
realpath_cache_size = 4096k
|
realpath_cache_size = 4096k
|
||||||
realpath_cache_ttl = 600
|
realpath_cache_ttl = 600
|
||||||
|
|||||||
+18
-2
@@ -1,14 +1,28 @@
|
|||||||
[supervisord]
|
[supervisord]
|
||||||
nodaemon=true
|
nodaemon=true
|
||||||
user=root
|
# Run the whole stack as the non-root www-data user (security hardening).
|
||||||
|
# nginx listens on 8080 (non-privileged) so root is not needed to bind the port.
|
||||||
|
user=www-data
|
||||||
logfile=/dev/stdout
|
logfile=/dev/stdout
|
||||||
logfile_maxbytes=0
|
logfile_maxbytes=0
|
||||||
pidfile=/run/supervisord.pid
|
pidfile=/tmp/supervisord.pid
|
||||||
|
|
||||||
|
[unix_http_server]
|
||||||
|
file=/tmp/supervisor.sock
|
||||||
|
|
||||||
|
[supervisorctl]
|
||||||
|
serverurl=unix:///tmp/supervisor.sock
|
||||||
|
|
||||||
|
[rpcinterface:supervisor]
|
||||||
|
supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface
|
||||||
|
|
||||||
[program:php-fpm]
|
[program:php-fpm]
|
||||||
command=php-fpm -F
|
command=php-fpm -F
|
||||||
autorestart=true
|
autorestart=true
|
||||||
priority=10
|
priority=10
|
||||||
|
# Forward SIGTERM (graceful) and give workers time to finish in-flight requests.
|
||||||
|
stopsignal=TERM
|
||||||
|
stopwaitsecs=15
|
||||||
stdout_logfile=/dev/stdout
|
stdout_logfile=/dev/stdout
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
@@ -18,6 +32,8 @@ stderr_logfile_maxbytes=0
|
|||||||
command=nginx -g 'daemon off;'
|
command=nginx -g 'daemon off;'
|
||||||
autorestart=true
|
autorestart=true
|
||||||
priority=20
|
priority=20
|
||||||
|
stopsignal=QUIT
|
||||||
|
stopwaitsecs=15
|
||||||
stdout_logfile=/dev/stdout
|
stdout_logfile=/dev/stdout
|
||||||
stdout_logfile_maxbytes=0
|
stdout_logfile_maxbytes=0
|
||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
|
|||||||
+17
-3
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
| سرویس | نقش | نکته |
|
| سرویس | نقش | نکته |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `app` | PHP-FPM + Nginx (وب) | تنها سرویسی که `RUN_INIT=1` دارد؛ مهاجرت DB و تولید کلید JWT را اجرا میکند. دامنهها را به این سرویس (پورت 80) وصل کن. |
|
| `app` | PHP-FPM + Nginx (وب، non-root، پورت ۸۰۸۰) | تنها سرویسی که `RUN_INIT=1` دارد؛ مهاجرت DB و تولید کلید JWT را اجرا میکند. دامنهها را به این سرویس (پورت ۸۰۸۰) وصل کن. |
|
||||||
| `worker-async` | مصرفکننده صف async (SMS و کارهای async) | `messenger:consume async` |
|
| `worker-async` | مصرفکننده صف async (SMS و کارهای async) | `messenger:consume async` |
|
||||||
| `worker-scheduler` | زمانبند | هر ۱ دقیقه نوبتهای پرداختنشده را منقضی میکند |
|
| `worker-scheduler` | زمانبند | هر ۱ دقیقه نوبتهای پرداختنشده را منقضی میکند |
|
||||||
|
|
||||||
@@ -60,7 +60,9 @@
|
|||||||
|
|
||||||
## مرحله ۳ — دامنهها
|
## مرحله ۳ — دامنهها
|
||||||
|
|
||||||
همهی دامنههای سرو شونده را به سرویس **`app`** (پورت 80) اختصاص بده — هم دامنهی API و هم همهی دامنههای فرانتاند. Coolify لیست دامنهی جداشده با کاما را روی یک سرویس قبول میکند و TLS را خودش صادر میکند.
|
همهی دامنههای سرو شونده را به سرویس **`app`** اختصاص بده — هم دامنهی API و هم همهی دامنههای فرانتاند. Coolify لیست دامنهی جداشده با کاما را روی یک سرویس قبول میکند و TLS را خودش صادر میکند.
|
||||||
|
|
||||||
|
> ⚠️ **پورت سرویس = `8080`** (نه ۸۰). کانتینر non-root اجرا میشود و nginx روی پورت غیرممتاز ۸۰۸۰ گوش میدهد. در Coolify port سرویس `app` را روی **۸۰۸۰** بگذار (Traefik به هر پورتی روت میکند — طبق داک، هر پورتی مجاز است).
|
||||||
|
|
||||||
> اجازهدادن CORS و host فرانتاندها از طریق متغیرهای `CORS_ALLOW_ORIGIN` / `ALLOWED_FRONTEND_HOSTS` کنترل میشود، نه دامنهی Coolify.
|
> اجازهدادن CORS و host فرانتاندها از طریق متغیرهای `CORS_ALLOW_ORIGIN` / `ALLOWED_FRONTEND_HOSTS` کنترل میشود، نه دامنهی Coolify.
|
||||||
|
|
||||||
@@ -164,12 +166,24 @@ php bin/console app:create-admin
|
|||||||
|
|
||||||
### بررسی سلامت
|
### بررسی سلامت
|
||||||
|
|
||||||
- healthcheck سرویس `app`: `php fsockopen 127.0.0.1:80`.
|
- healthcheck سرویس `app`: `docker/healthcheck.sh` route واقعی `/health` را روی پورت ۸۰۸۰ میزند (نه فقط چک پورت).
|
||||||
|
- workerها: healthcheck زندهبودن پروسهی `messenger:consume` با `ps`.
|
||||||
- Swagger: `https://<APP_BASE_URL>/api/doc`
|
- Swagger: `https://<APP_BASE_URL>/api/doc`
|
||||||
- پنل ادمین: `https://<APP_BASE_URL>/admin`
|
- پنل ادمین: `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 دستی). در هر ریدیپلوی:
|
push روی برنچ متصل (یا Deploy دستی). در هر ریدیپلوی:
|
||||||
|
|||||||
Reference in New Issue
Block a user