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 sudo privileges.
  • Nginx installed (sudo apt install nginx). The steps also work behind Apache with mod_proxy_fcgi, but the examples use Nginx.
  • A domain name pointing to the server, used here as your_domain. You can use your_server_ip while 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/:

PathPurpose
/etc/php/8.3/fpm/php-fpm.confGlobal settings for the master process
/etc/php/8.3/fpm/pool.d/*.confOne file per pool; www.conf is the default pool
/etc/php/8.3/fpm/php.iniPHP 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:

  • user and group: the PHP workers run as example, so PHP code can only write where that user can.
  • listen.owner and listen.group: the socket belongs to www-data so that Nginx, which runs as www-data, can connect to it.
  • pm = dynamic: PHP-FPM keeps between pm.min_spare_servers and pm.max_spare_servers idle workers and never runs more than pm.max_children. You will size pm.max_children in 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_value sets a value the application cannot override with ini_set(); php_value sets 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 to www-data. Check listen.owner, listen.group and listen.mode in the pool file.
  • 502 with No such file or directory: the pool did not start, so the socket was not created. Run sudo php-fpm8.3 -t and read sudo 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 denied when 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.