Migrating a Multi-Site Apache Server from mod_php to PHP-FPM
TL;DR: A ~20-vhost Apache server had both mod_php and PHP-FPM installed, but a config precedence bug meant every request was silently falling back to mod_php — the heavier, per-worker-memory architecture. A handful of Apache workers were observed holding 2–4 GB of resident memory each. Tracking that down led to fixing three separate, unrelated bugs before FPM actually took over: a duplicate listen directive in the FPM pool config, a dead TCP target in Apache’s proxy config, and a global SetHandler directive that was silently outranking the FPM include. Along the way, one of the memory spikes turned out to be an unrelated Wordfence plugin bug, not a FPM/mod_php issue at all — worth separating application-level causes from infrastructure-level ones before changing architecture.
How it started: a few Apache workers holding gigabytes of memory
A routine memory-footprint review turned up several httpd worker processes sitting at 2.4–3.9 GB resident memory each — on a server also running MySQL, mail services, and everything else. That’s far outside the normal range for mod_php, which typically runs tens of MB of overhead per worker, not gigabytes.
First finding: two PHP execution paths were both installed
The server had both LoadModule php_module (mod_php, PHP running inside every Apache worker) and a configured PHP-FPM pool with its own proxy_fcgi_module routing — but only one should ever actually be in use. A direct test settled which one was live:
echo '<?php phpinfo(); ?>' > /path/to/docroot/phpinfo-test.php
curl -s -H "Host: example.com" http://localhost/phpinfo-test.php | grep -A1 "Server API"
The phpinfo() “Server API” line is the reliable way to tell these apart — response headers like X-Powered-By reflect a php.ini setting, not which SAPI actually served the request, and don’t distinguish the two.
Result: Apache 2.0 Handler — confirming mod_php, not FPM, was handling every request despite FPM being installed and running.
Second finding: the FPM pool had a broken listen directive
The pool config (www.conf) had two conflicting lines:
listen = 127.0.0.1:9000
listen = /var/run/php-fpm.sock
PHP-FPM only honors one listen directive per pool — the last one wins. So FPM was actually listening only on the unix socket, silently ignoring the TCP port entirely, regardless of which one was originally intended.
Third finding: Apache’s proxy config pointed at the wrong target
Meanwhile, the Apache-side FPM routing config was still pointed at the now-dead TCP port:
SetHandler "proxy:fcgi://127.0.0.1:9000"
Fixed to target the actual listening socket:
SetHandler "proxy:unix:/var/run/php-fpm.sock|fcgi://localhost/"
Fourth finding: a global directive was outranking the FPM include
Even with the socket path fixed, requests still weren’t reaching FPM. The actual cause: the main httpd.conf had its own inline handler for mod_php:
<FilesMatch "\.php$">
SetHandler application/x-httpd-php
</FilesMatch>
defined after the IncludeOptional line that pulled in the FPM config. In Apache, when two same-scope directives set the same thing, the one parsed later in the file wins — so this later block was silently overriding FPM’s handler for every single vhost, server-wide, regardless of how correctly FPM itself was configured.
The fix was to comment out this global block entirely, letting the FPM include become the effective default:
#<FilesMatch "\.php$">
# SetHandler application/x-httpd-php
#</FilesMatch>
Preserving one legitimate mod_php carve-out
One narrow, deliberate exception existed — a Content-Security-Policy report endpoint explicitly pinned to mod_php inside its own <Directory> block:
<Directory "/usr/local/doc/CSP">
<FilesMatch "\.php$">
SetHandler application/x-httpd-php
</FilesMatch>
</Directory>
Since a Directory-scoped directive is always more specific than a global default, this kept working exactly as before, untouched by the global change — a useful reminder that removing a broad default doesn’t require hunting down and preserving every narrow exception separately; Apache’s own scoping rules do that automatically.
Sizing the FPM pool
With FPM now actually in the request path, the pool needed real capacity planning rather than its original defaults (a pm.max_children of 5, evidently sized for a single low-traffic site rather than roughly twenty production domains):
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 300
php_value[memory_limit] = 256M
pm.max_requests is FPM’s equivalent of Apache’s MaxConnectionsPerChild — periodically recycling workers to prevent the same kind of unbounded memory creep that caused the original problem. The pool-level memory_limit caps how much any single worker can ever consume, containing a runaway or malicious request before it can repeat the multi-gigabyte pattern seen with mod_php.
Using php_value rather than php_admin_value for the memory limit matters here: WordPress’s own WP_MAX_MEMORY_LIMIT mechanism (used to grant more headroom specifically inside wp-admin, e.g. for image uploads) only works if the underlying PHP setting can still be overridden at runtime — php_admin_value would silently block that.
// in wp-config.php
define('WP_MEMORY_LIMIT', '256M'); // ordinary frontend requests
define('WP_MAX_MEMORY_LIMIT', '1024M'); // wp-admin, including image processing
A parallel discovery: not every memory spike was about the SAPI at all
One of the original heavy workers turned out to be unrelated to the mod_php/FPM question entirely — a PHP fatal error from the Wordfence security plugin’s compiled firewall ruleset hitting the memory ceiling on a recurring scheduled task:
PHP Fatal error: Allowed memory size of 268435456 bytes exhausted
in wp-content/wflogs/rules.php on line 1533
The fix there was unrelated to server architecture — removing the compiled rules file and letting the plugin regenerate it fresh resolved the crash. Worth calling out as a general lesson: not every symptom that looks like an infrastructure problem is one. Confirming what a specific process was actually doing (via its logs, or by matching its network connections to access-log entries) before redesigning architecture around it saved a lot of unnecessary complexity here.
Verifying the result
PID COMMAND RES
434 php-fpm 149M
433 php-fpm 189M
432 php-fpm 173M
431 php-fpm 139M
...
httpd workers: 37M – 74M each
Apache workers dropped to a normal, lightweight footprint — they now only proxy requests rather than executing PHP directly. The FPM workers doing the real work stayed comfortably under the new memory ceiling, nowhere near the multi-gigabyte range seen before the migration.
Summary of the fixes, in order
- Confirmed the actual active SAPI with a
phpinfo()“Server API” check, rather than assuming from config files alone. - Fixed the FPM pool’s duplicate
listendirective, consolidating on a single unix socket. - Fixed Apache’s proxy target to match that socket.
- Found and removed the global
mod_phpdirective that was silently outranking the FPM include for every vhost. - Left one legitimate, narrowly-scoped
mod_phpexception in place, relying on Apache’s directory-scoping rules rather than manual carve-outs. - Right-sized the FPM pool’s concurrency and memory limits for real, measured traffic rather than inherited defaults.
- Separated an unrelated application-level bug (a WordPress security plugin crash) from the actual infrastructure issue, rather than conflating the two.
Written up during a FreeBSD base-system upgrade project, where the memory investigation surfaced as a side finding. Config paths and specific values reflect the server in question at the time and may not generalize directly to other setups.