Introduction
PHP-FPM (FastCGI Process Manager) is the standard way to run PHP in prod behind Nginx or Apache. It handles:
- Pre-forked PHP worker pool
- Memory recycling (max_requests)
- Per-user / per-site isolation
- Slow log to identify slow requests
- Healthcheck for load balancer
Bad config = 502 Bad Gateway, OOM, wasted RAM. Good config = stability and performance.
Prerequisites
- Linux VPS Debian / Ubuntu
- Nginx or Apache already installed
- Basic PHP knowledge
Step 1: Installation
sudo apt update
sudo apt install -y php8.2-fpm php8.2-mysql php8.2-redis php8.2-curl php8.2-mbstring php8.2-xml php8.2-zip php8.2-gd php8.2-intl
sudo systemctl enable --now php8.2-fpm
Step 2: Pool anatomy
/etc/php/8.2/fpm/pool.d/www.conf:
[www]
user = www-data
group = www-data
listen = /run/php/php8.2-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 10
pm.max_requests = 500
pm.status_path = /fpm-status
ping.path = /fpm-ping
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 5s
request_terminate_timeout = 60s
Step 3: Process Manager (pm)
3 modes:
- static: fixed worker count. Predictable, stable RAM.
- dynamic: varies between min and max. Good balance.
- ondemand: created on demand. RAM-efficient, higher latency.
Prod recommendation: dynamic.
Step 4: max_children calculation
Formula: pm.max_children = (available RAM) / (avg RAM per worker)
ps -ylC php-fpm8.2 --sort:rss | head
RSS column in KB. If avg 50 MB and 4 GB RAM for PHP:
pm.max_children = 4096 / 50 = ~80
Stay conservative: 60-70.
Step 5: One pool per site
sudo nano /etc/php/8.2/fpm/pool.d/site1.conf
[site1]
user = site1
group = site1
listen = /run/php/php8.2-site1.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 20
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
slowlog = /var/log/php-fpm/site1-slow.log
request_slowlog_timeout = 3s
php_admin_value[open_basedir] = /var/www/site1:/tmp
php_admin_value[upload_tmp_dir] = /var/www/site1/tmp
php_admin_value[session.save_path] = /var/www/site1/sessions
php_admin_value[memory_limit] = 256M
sudo useradd -r -s /bin/false site1
sudo mkdir -p /var/www/site1/{tmp,sessions}
sudo chown -R site1:site1 /var/www/site1
sudo systemctl restart php8.2-fpm
Step 6: Nginx config for the pool
server {
listen 443 ssl http2;
server_name site1.com;
root /var/www/site1;
index index.php;
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-site1.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
}
Step 7: Opcache
/etc/php/8.2/fpm/php.ini:
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.validate_timestamps=1
opcache.revalidate_freq=60
opcache.fast_shutdown=1
opcache.save_comments=1
In stable prod, set opcache.validate_timestamps=0 (10-15% perf gain, needs systemctl reload after each deploy).
Step 8: JIT (PHP 8+)
opcache.jit=1255
opcache.jit_buffer_size=128M
Gain: 5-15% on compute-intensive PHP.
Step 9: Slow log
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 3s
sudo tail -f /var/log/php-fpm/slow.log
Identifies precise stack trace at slowdown moment.
Step 10: FPM status + monitoring
pm.status_path = /fpm-status
ping.path = /fpm-ping
Nginx:
location ~ ^/(fpm-status|fpm-ping)$ {
access_log off;
allow 127.0.0.1;
deny all;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
include fastcgi_params;
}
curl http://localhost/fpm-status?full
Integrate into Prometheus with php-fpm_exporter.
Step 11: Healthcheck for load balancer
curl http://localhost/fpm-ping
Returns pong. Use this path in HAProxy, Traefik, AWS ELB.
Step 12: memory_limit and timeouts
php_admin_value[memory_limit] = 256M
php_admin_value[max_execution_time] = 60
php_admin_value[max_input_time] = 60
php_admin_value[post_max_size] = 64M
php_admin_value[upload_max_filesize] = 64M
request_terminate_timeout = 65s
Set request_terminate_timeout slightly higher than max_execution_time.
Troubleshooting
502 Bad Gateway
sudo systemctl status php8.2-fpm
sudo tail -f /var/log/php-fpm/error.log
sudo journalctl -u php8.2-fpm -n 50
ls -la /run/php/php8.2-fpm.sock
"server reached pm.max_children setting"
Saturation. Increase pm.max_children, identify slow queries.
Memory leak
pm.max_requests = 100
"MySQL has gone away"
FPM workers keep long connections. Increase MySQL wait_timeout or use PDO::ATTR_PERSISTENT = false.
File upload limit
Check upload_max_filesize, post_max_size, and Nginx client_max_body_size.
Useful commands
sudo systemctl status php8.2-fpm
sudo systemctl reload php8.2-fpm # no downtime
sudo php-fpm8.2 -t # test config
sudo php-fpm8.2 -tt # list pools
watch 'curl -s http://localhost/fpm-status'
ps -eo rss,cmd | grep php-fpm | awk '{sum+=$1} END {print sum/1024 " MB"}'
ps aux --sort=-rss | grep php-fpm | head
Conclusion
A healthy PHP-FPM config gives you:
- Predictable memory
- Per-site isolation
- Visibility on slow requests
- Healthcheck for load balancer
Going further:
- Combine with OPcache file cache (cache between restarts)
- Migrate to RoadRunner for extreme perf (PHP daemon mode)
- Use FrankenPHP for integrated PHP + web server
Resources
- Official docs: https://www.php.net/manual/en/install.fpm.configuration.php
- php-fpm_exporter: https://github.com/hipages/php-fpm_exporter
- RoadRunner: https://roadrunner.dev
- FrankenPHP: https://frankenphp.dev

















