One PHP pool per site: isolating several self-hosted WordPress sites on one server

Photo by Andrew Shelley on Unsplash

Four WordPress sites run on one self-managed virtual private server (VPS), and they arrived through a blue-green migration off managed hosting. They share an Apache web server and a PHP runtime, and the default way to run PHP points every one of them at the same pool. One shared pool means one process context and one socket for the whole fleet, so a single site’s compromise or crash reaches the others. This is the ground behind Fast, secure, self-managed WordPress.

The constraint was parity with the source: Gandi Simple Hosting gave each site its own PHP process over a Unix socket, not a shared TCP pool, and I wanted that separation inside the box’s memory budget.

TL;DR

I disabled the default www PHP pool and gave each site its own FPM pool on its own Unix socket, jailed PHP to each docroot with open_basedir, and bound MySQL to the loopback interface. No site shares a PHP process context with another, and a compromised install cannot read outside its own webroot. The data stack stays shared; the process and filesystem layer is what is split.

Several sites, one shared PHP process

The trap is that the shared pool is the default and cheap to keep. PHP-FPM ships a www pool that answers every virtual host pointed at it, so several sites behind one pool run in one PHP user’s process context with one socket among them. Anything that reaches one site arrives in the same process space as the rest.

I rejected keeping that shared pool and treating the sites as separable by policy. It gives up the isolation the fleet needs, and the schedule that runs each site’s maintenance work assumes that separation rather than providing it.

The design decision

Five decisions carry the isolation, and each had a live alternative.

  • One FPM pool per site. The default www pool is disabled, renamed to .disabled, and each site gets its own pool listening on /run/php/php-fpm-SITE_SLUG.sock, with its own log, slowlog, and error paths.
  • An open_basedir jail per docroot. Each pool confines PHP to the site’s own htdocs and tmp directories plus the sessions directory, so a compromised install cannot read another site’s files or the disk.
  • Per-site Apache socket wiring. Each vhost reaches PHP through its own socket with SetHandler "proxy:unix:/run/php/...sock|fcgi://localhost/", so one site’s traffic lands in that site’s pool.
  • A localhost-only MySQL posture. The engine stays bound to 127.0.0.1 and 3306/tcp stays out of the firewall, so the shared service is never on the network.
  • One idempotent provisioning block per site. A single block builds the filesystem, the database and user, the pool, the vhost, the log rotation, and the plugin set, so every site is provisioned alike and the block re-runs safely.

Each alternative was live and failed on a stated reason. For the process, the jail, and the wiring, the answer turned down was to keep a single shared pool, with or without a jail. For the data stack, it was to allow 3306/tcp through the firewall, which throws away the localhost-only posture. For provisioning, it was ad-hoc copy-paste per site, which drifts.

How the isolation works

Each pool is one template copied with the site’s slug in the socket path and log paths. The jail is set per pool with php_admin_value[open_basedir] pointing at /var/www/SITE_SLUG/htdocs:/var/www/SITE_SLUG/tmp:/var/lib/php/sessions, and the same mechanism sets the pool’s error_log, session.save_path, sys_temp_dir, and upload_tmp_dir, so each site keeps its session state to itself. Apache wires the site with SetHandler "proxy:unix:/run/php/php-fpm-SITE_SLUG.sock|fcgi://localhost/", and the vhost is not enabled until its socket exists.

The provisioning block runs once per site in a fixed, risk-graduated order: foozilla first as a smoke test, then jngirard, then clementbodot, and carolegirard last as the critical site. It sources its variables from a temporary file that is shredded after the run, so the database passwords do not persist on disk.

Isolation layerWhat carries it
PHP process separationone FPM pool per site, each on its own Unix socket
Filesystem read boundaryopen_basedir to the site’s htdocs, tmp, and sessions paths
Per-site temporary and session stateper-pool php_admin_value for session.save_path, sys_temp_dir, and upload_tmp_dir
Web-server wiringSetHandler to the site’s socket over `proxy:unix:…
Database exposureMySQL bound to 127.0.0.1, with 3306/tcp absent from the firewall
Repeatabilityone idempotent provisioning block per site, run in a fixed order

What broke, and what did not

The one break the isolation stack carries is on the data-stack leg. INC-003 records MySQL listening on 0.0.0.0:3306, or 3306/tcp allowed through the firewall, either of which puts the database on the network; the playbook writes the root cause as a hypothesis, a bind address or firewall rule that drifted from the localhost-only baseline. The repair was to deny 3306/tcp, restart the service, and confirm that only 127.0.0.1:3306 listens. The three isolation decisions themselves report Broke: none, and I leave that stated: the pool, the jail, and the provisioning block went in as design, not as a repair.

A second failure belongs to this register. INC-018 records a vhost returning 502 because Apache pointed at a per-site socket that did not exist, and INC-002 records php-fpm -t failing to report a successful configuration test. Both root causes are hypothesis-only, so I carry them as hypothesis, not as confirmed mechanism. That guard is the socket-existence test above.

What it costs to run

The records carry two figures and no money figure. The pool memory cost is the record’s own math: three pools at 10 max_children each, roughly 40MB per PHP process, about 1.2GB at peak. That is a peak, the budget the box was sized against. The jail’s performance cost is about 2%, accepted so a compromised install stays inside its webroot; the record calls that acceptable for multi-site shared FPM. No monetary cost is recorded, so I assert none.

What this does not solve

The jail is a PHP-level read boundary, not the operating-system boundary. open_basedir limits what PHP may read; it does not set filesystem permissions, and it does not constrain any other process on the box. The layer that governs what a process can reach at all is the operating-system boundary and its user account.

The sites still share the data stack. MySQL is bound to the loopback interface and out of the firewall, but all four sites reach the same MySQL service, so the isolation is per-site process and filesystem, not a split of the engine.

The provisioning block is idempotent, but I run it once per site by hand. It is not daily-automation discipline: nothing re-checks the pool, the jail, or the socket on a schedule.

Two adjacent subjects stay outside this register. Where a workload runs at all, on local hardware or in the cloud, is a separate decision. The scheduler that fires a personal automation is another.

The pattern I would start from is the one above: one pool per site on its own socket, a jail per docroot, and a data stack that never leaves the loopback interface. The trade-offs I accepted to run this stack are on the about page. If this is the shape of your problem, let’s talk.