---
title: "Nginx + PHP-FPM Pool Separation"
description: "The concrete benefits of defining a separate pool per application instead of a single PHP-FPM pool when running multiple PHP apps on one VPS"
url: https://sade.dev/en/notes/nginx-php-fpm-pool-separation/
lang: en
author: "Muhammet Şafak"
published: 2026-04-26
updated: 2026-09-06
section: Note
tags: ["nginx","php-fpm","production","vps"]
summary: "A separate PHP-FPM pool per application separates two things: filesystem access and the worker budget. What it does not separate is opcache and reload — the pools share one master, and its single reload signal takes every worker down with it. The isolation holds at runtime, not on the deploy path. Set it up knowing which of the two you bought."
---

# Nginx + PHP-FPM Pool Separation

> A separate PHP-FPM pool per application separates two things: filesystem access and the worker budget. What it does not separate is opcache and reload — the pools share one master, and its single reload signal takes every worker down with it. The isolation holds at runtime, not on the deploy path. Set it up knowing which of the two you bought.

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.

## What goes wrong 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`:

```ini
[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`:

```ini
[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:

```nginx
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:

```ini
pm.status_path = /status
```

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

```nginx
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**.

## Frequently asked

**Does pool separation isolate opcache between applications?**

No. The pools run under one PHP-FPM master and share its opcache. opcache.validate_root does not change that either: it exists to prevent name collisions in chrooted environments, and these pools have no chroot. Separate pools buy filesystem and resource isolation, not a separate opcode cache.

**Can a single PHP-FPM pool be reloaded on its own?**

No. FPM has one reload signal, SIGUSR2, and it performs a graceful reload of all workers plus a reload of the FPM config. There is no per-pool signal, which is why restarting one application still recycles every other pool on the machine.

**When is a single pool enough?**

When the applications are the same process class — two copies of one codebase, for example production and staging on different domains. Separation earns its keep when you want the applications to be operationally independent of each other.


## Sources

- [FPM configuration: per-pool user, listen, pm.max_children and pm.status_path](https://www.php.net/manual/en/install.fpm.configuration.php) — PHP
- [php-fpm(8): SIGUSR2 is a graceful reload of all workers plus a reload of the FPM config](https://github.com/php/php-src/blob/master/sapi/fpm/php-fpm.8.in) — PHP
- [OPcache configuration: opcache.validate_root prevents name collisions in chrooted environments](https://www.php.net/manual/en/opcache.configuration.php) — PHP
- [FPM status page: active processes, listen queue and slow requests](https://www.php.net/manual/en/fpm.status.php) — PHP
