PHP-FPM (FastCGI Process Manager) runs PHP as a separate service with a pool of worker processes that web servers such as Nginx talk to over a socket. Each pool can run as its own Linux user, with its own limits and PHP settings, which makes PHP-FPM the standard way to run PHP in production. In this tutorial you will install PHP-FPM 8.3 on Ubuntu 24.04, create a dedicated pool for one site, size it to your server's memory, connect it to Nginx and enable the status page and slow request log.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 1 GB of RAM.
- A non-root user with
sudoprivileges. - Nginx installed (
sudo apt install nginx). The steps also work behind Apache withmod_proxy_fcgi, but the examples use Nginx. - A domain name pointing to the server, used here as
your_domain. You can useyour_server_ipwhile testing.
Step 1 - Installing PHP-FPM
Install PHP-FPM together with the extensions most applications need:
sudo apt update
sudo apt install php8.3-fpm php8.3-mysql php8.3-curl php8.3-gd php8.3-mbstring php8.3-xml php8.3-zip php8.3-intl
The package starts the php8.3-fpm service and enables it at boot. Check its state:
sudo systemctl status php8.3-fpm --no-pager
● php8.3-fpm.service - The PHP 8.3 FastCGI Process Manager
Loaded: loaded (/usr/lib/systemd/system/php8.3-fpm.service; enabled; preset: enabled)
Active: active (running) since ...
Status: "Ready to handle connections"
On Ubuntu the PHP-FPM configuration lives in /etc/php/8.3/fpm/:
| Path | Purpose |
|---|---|
/etc/php/8.3/fpm/php-fpm.conf | Global settings for the master process |
/etc/php/8.3/fpm/pool.d/*.conf | One file per pool; www.conf is the default pool |
/etc/php/8.3/fpm/php.ini | PHP settings used by FPM (the CLI has its own php.ini) |
/etc/php/8.3/mods-available/ | Per-extension settings, such as opcache.ini |
Step 2 - Creating a dedicated user and pool for your site
The default www pool runs every site as www-data. If one application is compromised, it can read the files of all the others. A separate pool per site, running as its own user, isolates them and lets you tune each one independently.
Create a system user for the site, without a login shell:
sudo useradd --system --user-group --no-create-home --shell /usr/sbin/nologin example
Create the document root and a test page, owned by that user:
sudo mkdir -p /var/www/your_domain
echo '<?php echo "Running as " . posix_getpwuid(posix_geteuid())["name"] . PHP_EOL;' | sudo tee /var/www/your_domain/index.php > /dev/null
sudo chown -R example:example /var/www/your_domain
PHP workers write their error log as the pool user, so create a log directory that example owns:
sudo mkdir -p /var/log/php-fpm-example
sudo chown example:example /var/log/php-fpm-example
Create the pool configuration file:
sudo nano /etc/php/8.3/fpm/pool.d/example.conf
Add the following pool definition:
[example]
user = example
group = example
listen = /run/php/php8.3-fpm-example.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 10
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
pm.max_requests = 500
pm.status_path = /fpm-status
ping.path = /fpm-ping
slowlog = /var/log/php-fpm-example/slow.log
request_slowlog_timeout = 5s
request_terminate_timeout = 60s
php_admin_value[error_log] = /var/log/php-fpm-example/error.log
php_admin_flag[log_errors] = on
php_admin_value[memory_limit] = 256M
php_value[upload_max_filesize] = 32M
php_value[post_max_size] = 32M
The important settings:
userandgroup: the PHP workers run asexample, so PHP code can only write where that user can.listen.ownerandlisten.group: the socket belongs towww-dataso that Nginx, which runs aswww-data, can connect to it.pm = dynamic: PHP-FPM keeps betweenpm.min_spare_serversandpm.max_spare_serversidle workers and never runs more thanpm.max_children. You will sizepm.max_childrenin the next step.pm.max_requests: each worker is recycled after 500 requests, which contains slow memory leaks in application code.request_terminate_timeout: kills a request that runs longer than 60 seconds so a stuck script cannot hold a worker forever.php_admin_valuesets a value the application cannot override withini_set();php_valuesets a default the application may change.
Test the configuration before applying it:
sudo php-fpm8.3 -t
[...] NOTICE: configuration file /etc/php/8.3/fpm/php-fpm.conf test is successful
Reload PHP-FPM. A reload starts the new pool without dropping requests in progress:
sudo systemctl reload php8.3-fpm
Confirm that the new socket exists and that workers run as example:
ls -l /run/php/php8.3-fpm-example.sock
ps -o user,pid,cmd -C php-fpm8.3
srw-rw---- 1 www-data www-data 0 ... /run/php/php8.3-fpm-example.sock
USER PID CMD
root 1234 php-fpm: master process (/etc/php/8.3/fpm/php-fpm.conf)
example 1301 php-fpm: pool example
example 1302 php-fpm: pool example
example 1303 php-fpm: pool example
www-data 1240 php-fpm: pool www
www-data 1241 php-fpm: pool www
If no site uses the default www pool anymore, you can disable it by renaming it (sudo mv /etc/php/8.3/fpm/pool.d/www.conf /etc/php/8.3/fpm/pool.d/www.conf.disabled) and reloading. Only files ending in .conf are loaded. Keep at least one pool: PHP-FPM refuses to start without any.
Step 3 - Sizing pm.max_children to your memory
pm.max_children is the most important PHP-FPM setting. Too low and requests queue up during traffic peaks; too high and the server runs out of memory and the kernel starts killing processes.
The rule is simple: divide the memory you can give PHP by the average size of one worker. Once the site has received some real traffic, measure the average resident memory of the pool's workers:
ps --no-headers -o rss -u example | awk '{ sum += $1; n++ } END { if (n) printf "%d workers, %.0f MB average\n", n, sum / n / 1024 }'
3 workers, 48 MB average
Then check how much memory is available:
free -m
As an example, on a 2 GB server where MySQL and Nginx use about 700 MB and you keep 300 MB free for the page cache, about 1,000 MB is left for PHP. With workers of about 50 MB:
1000 MB / 50 MB = 20 -> pm.max_children = 20
Set the spare servers relative to that value. A common starting point is pm.start_servers around 20 % of pm.max_children, pm.min_spare_servers around 10 % and pm.max_spare_servers around 30 %. If you host many low-traffic sites on one server, pm = ondemand with pm.process_idle_timeout = 10s saves memory by starting workers only when requests arrive.
After changing the values, test and reload again:
sudo php-fpm8.3 -t && sudo systemctl reload php8.3-fpm
When the pool reaches its limit, PHP-FPM writes a warning to its log. Check for it regularly:
sudo grep max_children /var/log/php8.3-fpm.log
WARNING: [pool example] server reached pm.max_children setting (10), consider raising it
If this appears often and you have free memory, raise pm.max_children. If you don't have free memory, look at the slow log (Step 5) before adding workers.
Step 4 - Connecting Nginx to the pool
Create a server block for the site:
sudo nano /etc/nginx/sites-available/your_domain
Add this configuration. The fastcgi_pass line points to the pool's socket, and the status and ping locations are only reachable from the server itself:
server {
listen 80;
listen [::]:80;
server_name your_domain www.your_domain;
root /var/www/your_domain;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm-example.sock;
}
location ~ ^/(fpm-status|fpm-ping)$ {
allow 127.0.0.1;
allow ::1;
deny all;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.3-fpm-example.sock;
}
}
Enable the site, test the configuration and reload Nginx:
sudo ln -s /etc/nginx/sites-available/your_domain /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Request the test page:
curl http://your_domain/
Running as example
The PHP code runs as the example user, so the pool is in use.
Step 5 - Monitoring the pool
Status page
Query the status page from the server. The Host header makes Nginx pick the right server block:
curl -s -H "Host: your_domain" http://127.0.0.1/fpm-status
pool: example
process manager: dynamic
start time: ...
accepted conn: 152
listen queue: 0
max listen queue: 0
idle processes: 2
active processes: 1
total processes: 3
max active processes: 3
max children reached: 0
slow requests: 0
Watch two values in particular. listen queue shows requests waiting for a free worker; it should be 0 most of the time. max children reached counts how often the pool hit pm.max_children since it started. Append ?full to the URL to see what each worker is running, and ?json for output that monitoring tools can parse.
The ping endpoint is useful for simple health checks:
curl -s -H "Host: your_domain" http://127.0.0.1/fpm-ping
pong
Slow log
Any request that runs longer than request_slowlog_timeout (5 seconds in this pool) is logged with a PHP stack trace, which shows the function where the script was stuck:
sudo tail -n 20 /var/log/php-fpm-example/slow.log
An empty log means no request has been that slow yet. Typical culprits are slow database queries and calls to external APIs without a timeout.
Step 6 - Tuning OPcache
OPcache stores compiled PHP scripts in shared memory so PHP does not parse the same files on every request. It is enabled by default for PHP-FPM on Ubuntu, but the default memory size is small for large applications. Edit its configuration file:
sudo nano /etc/php/8.3/mods-available/opcache.ini
Add these lines below the existing zend_extension=opcache.so line:
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=2
With validate_timestamps=1, PHP checks for changed files every revalidate_freq seconds, so deployments take effect without a restart. On servers where code only changes through a deployment process, you can set it to 0 for a little more speed and reload PHP-FPM after each deploy.
Reload PHP-FPM and confirm the values:
sudo systemctl reload php8.3-fpm
php-fpm8.3 -i | grep -E '^opcache.memory_consumption'
opcache.memory_consumption => 256 => 256
Troubleshooting
- 502 Bad Gateway with
connect() to unix:/run/php/... failed (13: Permission denied)in the Nginx error log: the socket is not accessible towww-data. Checklisten.owner,listen.groupandlisten.modein the pool file. - 502 with
No such file or directory: the pool did not start, so the socket was not created. Runsudo php-fpm8.3 -tand readsudo journalctl -u php8.3-fpm -n 50. - 504 Gateway Timeout: the script ran longer than Nginx's
fastcgi_read_timeout(60 seconds by default). Find the slow code in the slow log before raising any timeout. Permission deniedwhen the application writes files: the files are owned by another user. The pool user (example) needs write access to upload and cache directories.
Conclusion
You installed PHP-FPM 8.3 on Ubuntu 24.04, created an isolated pool that runs as its own user, sized it from real memory usage, connected it to Nginx and enabled the status page, slow log and OPcache. Repeat Step 2 and Step 4 for every additional site, then consider adding the status page to your monitoring system or running several PHP versions side by side with separate FPM services.
