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:
hamed
2026-06-28 15:27:34 +03:30
co-authored by Claude Opus 4.8
parent c74b06b3cc
commit 5150365d2c
8 changed files with 84 additions and 23 deletions
+20 -3
View File
@@ -85,19 +85,36 @@ RUN [ -f .env ] || printf 'APP_ENV=prod\nAPP_DEBUG=0\n' > .env
# Container configuration.
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/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 \
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 \
# 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
# and otherwise fails with "Permission denied" on /app/config/preload.php.
&& 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"]
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
+6 -4
View File
@@ -64,11 +64,9 @@ services:
RUN_INIT: "1" # runs JWT keygen + migrations on start (only this service)
volumes: *app-volumes
networks: [coolify]
# Hit the real /health route through nginx+fpm — verifies the app actually
# 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.
# Hit the real /health route (nginx on 8080, non-root) via docker/healthcheck.sh.
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
timeout: 5s
retries: 5
@@ -85,6 +83,9 @@ services:
volumes: *app-volumes
networks: [coolify]
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).
healthcheck:
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
networks: [coolify]
depends_on: [app]
stop_grace_period: 30s
healthcheck:
test: ["CMD-SHELL", "ps -o args 2>/dev/null | grep -q '[m]essenger:consume scheduler_default' || exit 1"]
interval: 30s
+6
View File
@@ -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);'
+4 -1
View File
@@ -1,5 +1,8 @@
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 _;
root /app/public;
+13
View File
@@ -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
-10
View File
@@ -6,16 +6,6 @@ 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
+18 -2
View File
@@ -1,14 +1,28 @@
[supervisord]
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_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]
command=php-fpm -F
autorestart=true
priority=10
# Forward SIGTERM (graceful) and give workers time to finish in-flight requests.
stopsignal=TERM
stopwaitsecs=15
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
@@ -18,6 +32,8 @@ stderr_logfile_maxbytes=0
command=nginx -g 'daemon off;'
autorestart=true
priority=20
stopsignal=QUIT
stopwaitsecs=15
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
+17 -3
View File
@@ -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-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.
@@ -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`
- پنل ادمین: `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 دستی). در هر ری‌دیپلوی: