Logging
Everything logs to the container's stdout and stderr by default, and every line says which service wrote it, so a single docker logs stream stays readable and filterable.
[entrypoint] INFO: Running as non-root (UID 1000). Starting nginx + php-fpm directly.
[php-fpm] [21-Oct-2026 17:49:03] NOTICE: ready to handle connections
[nginx] 172.17.0.1 - - [21/Oct/2026:17:49:07 +0000] "GET / HTTP/1.1" 200 13 "-" "curl/8.7.1"
Streams and tags
| Tag | Stream | Content |
|---|---|---|
[nginx] | stdout | access log (combined format) |
[nginx] | stderr | Nginx errors and warnings |
[php-fpm] | stderr | FPM master and pool messages, PHP error_log() output, slow-request traces |
[cron] | stderr | crond output (root mode only) |
[entrypoint] | stdout | startup messages from the entrypoint scripts |
| (untagged) | stderr | Supervisor's own messages in root mode, such as INFO spawned: 'nginx' |
docker logs -f app 2>&1 # everything
docker logs app 2>&1 | grep '^\[php-fpm\]' # one service
docker logs app 2>&1 | grep -v '^\[nginx\]' # everything except the access log
Three switches
| Variable | Default | Options |
|---|---|---|
LOG_MODE | stdout | stdout streams everything to the container. file writes one file per service under /var/log. |
LOG_FORMAT | text | text for tagged lines, json for one JSON object per line. |
LOG_TAG | true | false removes the [service] prefixes for anyone with an existing parser. |
JSON mode
LOG_FORMAT=json is for Loki, Datadog, CloudWatch and similar. Service messages become objects with time, service, stream and message, and the Nginx access log switches to a structured json_combined format:
{"time":"2026-10-21T17:48:05+00:00","service":"nginx","stream":"access","remote_addr":"172.17.0.1","x_forwarded_for":"","host":"localhost","method":"GET","uri":"/err.php","protocol":"HTTP/1.1","status":200,"bytes_sent":12,"request_time":0.002,"upstream_time":"0.002","referer":"","user_agent":"curl/8.7.1"}
{"time":"2026-10-21T17:48:00+0000","service":"php-fpm","stream":"stderr","message":"[21-Oct-2026 17:48:00] NOTICE: fpm is running, pid 42"}
docker logs app 2>&1 | jq -c 'select(.service == "nginx" and .status >= 500)'
Every emitted line is valid JSON, including entrypoint messages.
File mode
LOG_MODE=file writes untagged files and is useful when a sidecar or agent tails files:
| File | Content |
|---|---|
/var/log/nginx/access.log | access log |
/var/log/nginx/error.log | Nginx errors |
/var/log/php-fpm/error.log | FPM and PHP errors |
/var/log/php-fpm/slow.log | slow requests |
/var/log/cron.log, /var/log/supervisord.log | root mode only |
Mount /var/log to keep them, and tail them in place with the bundled helper:
docker exec app container-logs -f php-fpm # nginx | php-fpm | cron | supervisor | all
In stdout mode the same command prints the docker logs recipes above.
Individual destinations
NGINX_ACCESS_LOG, NGINX_ERROR_LOG, PHP_FPM_ERROR_LOG and PHP_FPM_SLOWLOG override single destinations whatever the mode. NGINX_ACCESS_LOG=off disables the access log entirely.
How it works
Each service is started through run-service <name> <command>, which pipes the process's stdout and stderr through a small awk filter that prefixes or JSON-wraps each line and flushes immediately. Lines that are already JSON objects pass through untouched, which is how the Nginx json_combined access log stays structured. Supervisor's own messages are left untagged because wrapping Supervisor would double-tag the output it relays from its programs.