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.
|
||||
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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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
|
||||
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
@@ -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
@@ -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 دستی). در هر ریدیپلوی:
|
||||
|
||||
Reference in New Issue
Block a user