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