Running two, three, five PHP applications on a VPS — perfectly normal in the small-SaaS world. In a default install they all share a single www.conf pool. This usually works — until it doesn’t.

The problem with a single pool

A single pool means:

  1. If one application exhausts pm.max_children = 50, the others can’t produce a response.
  2. A memory leak in one application drags the others’ workers into OOM too.
  3. Restarting one application (e.g. an opcache reset) restarts everyone’s workers.

Pool separation solves the first two. Not the third: PHP-FPM has no per-pool reload — the single reload signal, SIGUSR2, gracefully reloads all workers under the master.

Config per pool

/etc/php/8.4/fpm/pool.d/billing.conf:

[billing]
user = billing
group = billing
listen = /run/php/billing.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 500

request_terminate_timeout = 30s
catch_workers_output = yes
decorate_workers_output = no
clear_env = no

php_admin_value[memory_limit] = 256M
php_admin_value[error_log] = /var/log/php-fpm/billing-error.log

/etc/php/8.4/fpm/pool.d/notify.conf:

[notify]
user = notify
group = notify
listen = /run/php/notify.sock
; ...
pm.max_children = 8       ; lighter application
php_admin_value[memory_limit] = 128M

We gain two things:

  • Filesystem isolation — with user = billing, the billing app can’t write to notify’s files.
  • Resource isolation — if billing’s max_children is exhausted, notify still runs.

On the Nginx side

Each vhost connects to its own socket:

server {
    server_name billing.example.com;
    root /var/www/billing/public;

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/billing.sock;
        # standard fastcgi params
    }
}

server {
    server_name notify.example.com;
    root /var/www/notify/public;

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/notify.sock;
    }
}

opcache: shared or separate?

The PHP-FPM master process shares opcache — so opcache is not automatically isolated between pools. opcache.validate_root = 1 doesn’t change that: it prevents name collisions in chroot’ed environments by folding the inode of / into the cache key, so what it tells apart is chroot roots, not the Nginx root paths above — and these pools have no chroot. In practice: same master, separate pools is enough.

Monitoring

Expose the /status endpoint on the pool:

pm.status_path = /status

Then on the Nginx side, restrict it to localhost only:

location ~ ^/status$ {
    access_log off;
    allow 127.0.0.1;
    deny all;
    fastcgi_pass unix:/run/php/billing.sock;
    # ...
}

With curl localhost/status you can pull metrics like active workers, queue length, and slow request count, and monitor them with a Prometheus exporter or a simple cron.

When is a single pool enough?

If you’re running two copies of the same codebase (e.g. app.example.com and staging.example.com), a separate pool is unnecessary. Different domains but the same process class. Separation is worth it when you want applications to be operationally independent.