*An actual upgrade of a two-gigabyte Ubuntu server, with parallel PHP-FPM pools, a Unix-socket switchover, a rollback path, and a few surprises from systemd, LNMP.org, and WP-CLI.*
Upgrading PHP on a production WordPress server should be boring. Yet an apparently simple patch-level update can affect the entire request path: Nginx forwards requests to PHP-FPM, plugins depend on specific extensions, startup scripts may contain hard-coded paths, and scheduled maintenance commands can use a different PHP binary from the website.
I recently upgraded a live, self-hosted LNMP.org stack from **PHP 8.3.25 to PHP 8.3.36**. The server runs **Ubuntu 20.04**, **Nginx**, **MySQL 8.0**, and several WordPress sites, including a WooCommerce store. With only **2 GiB of RAM**, there was little room for a careless stop-and-restart procedure.
Instead of replacing `/usr/local/php` in place, I installed the new version beside it, tested it independently, and changed just the FastCGI socket used by Nginx. The login-page check returned **HTTP 200 before and after the switch**. That is useful evidence of a smooth cutover, although it is not proof that every visitor experienced zero interruption.
Here is what worked, what went wrong, and what I would do differently next time.
## The starting point: one PHP installation, several hidden dependencies
The old PHP-FPM service used the customary LNMP.org installation under `/usr/local/php`, listening on `/tmp/php-cgi.sock`. Nginx referred to that socket from two include files:
“`nginx
fastcgi_pass unix:/tmp/php-cgi.sock;
“`
The old process pool was modest:
“`ini
pm = ondemand
pm.max_children = 4
pm.process_idle_timeout = 10s
pm.max_requests = 300
“`
I wanted four things: preserve the existing site during preparation, verify Redis and OPcache under the new runtime, keep rollback as simple as one Nginx reload, and avoid changing MySQL or WordPress content.
The most important design choice was **not** to overwrite the old installation. I retained `/usr/local/php` for rollback and installed the new build at:
“`text
/opt/php-8.3.36
“`
A source build can require installation-specific `configure` flags and extensions, so I do not recommend blindly copying someone else’s build command. Match the previous build’s required modules and PHP configuration, verify the source release and checksum, and test against your own application’s dependency set.
## Stage 1: run the new PHP-FPM alongside the old one
I gave the new PHP-FPM its own Unix socket:
“`ini
listen = /run/php-8.3.36.sock
listen.owner = www
listen.group = www
listen.mode = 0660
pm = ondemand
pm.max_children = 4
pm.process_idle_timeout = 10s
pm.max_requests = 300
“`
The `0660` mode matters. The Nginx workers on this server run as `www`, so the socket needs to be accessible to that account, not writable by every local user. Both configurations could coexist because they listened on different paths.
The new service was separate from LNMP’s original `php-fpm.service`:
“`ini
[Unit]
Description=PHP 8.3.36 FastCGI Process Manager
After=network.target
[Service]
Type=simple
ExecStart=/opt/php-8.3.36/sbin/php-fpm -F -y /opt/php-8.3.36/etc/php-fpm.conf -c /opt/php-8.3.36/etc
ExecReload=/bin/kill -USR2 $MAINPID
KillSignal=SIGQUIT
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
“`
Before changing Nginx, I validated the FPM configuration and the new service:
“`bash
/opt/php-8.3.36/sbin/php-fpm -t \
-y /opt/php-8.3.36/etc/php-fpm.conf \
-c /opt/php-8.3.36/etc
systemctl status php-fpm-8.3.36 –no-pager
ls -l /run/php-8.3.36.sock
ss -xlpn | grep -E ‘php-cgi.sock|php-8.3.36.sock’
“`
The new and old PHP-FPM masters were both running. I also sent a direct FastCGI request to the new socket, using a temporary PHP probe, and checked the response rather than relying on `php -v` alone:
“`text
PHP_VERSION=8.3.36
PHP_SAPI=fpm-fcgi
REDIS=yes
OPCACHE=yes
FASTCGI TEST PASS
“`
This distinction matters: `php -v` tells you which CLI binary ran; it does not prove Nginx can execute a WordPress request through the intended FPM pool. Redis and OPcache were both available in my case. When reusing a compiled extension across patch releases, compatibility should be checked, not assumed merely because the file can be copied.
### A small startup race
After one service restart, `stat /run/php-8.3.36.sock` briefly failed even though systemd reported the service as active. A subsequent check showed the socket present and listening, and the FPM log reported `ready to handle connections`.
With `Type=simple`, a successful systemd start operation does not by itself guarantee that the application is ready to receive requests. I added an `ExecStartPost` readiness check for the socket and configured Nginx to start after the new PHP service. For a more rigorous check, the readiness probe can also attempt a connection as the `www` user.
“`ini
# /etc/systemd/system/nginx.service.d/php-order.conf
[Unit]
Wants=php-fpm-8.3.36.service
After=php-fpm-8.3.36.service
“`
These settings order startup and request that PHP-FPM be started with Nginx. They are not a substitute for runtime health monitoring.
## Stage 2: change only the Nginx FastCGI target
For this LNMP installation, both `enable-php.conf` and `enable-php-pathinfo.conf` still pointed to the old socket. I backed up both files and changed their upstream to:
“`nginx
fastcgi_pass unix:/run/php-8.3.36.sock;
“`
Then I tested and reloaded Nginx, without stopping it:
“`bash
/usr/local/nginx/sbin/nginx -t && \
/usr/local/nginx/sbin/nginx -s reload
“`
I verified the effective configuration using `nginx -T`, which showed the two active FastCGI references using the new socket. The WordPress login page returned HTTP 200 before and after the cutover. A direct local request used `curl –resolve` so the domain and TLS virtual host were preserved while routing to the local Nginx instance.
The rollback was deliberately uncomplicated. If the site failed its health checks, restore the two saved include files, run `nginx -t`, and reload Nginx again. **Keep the old PHP-FPM running until real application checks pass.** A successful login page alone cannot validate the shopping cart, checkout, payment integration, or scheduled tasks.
## Stage 3: the unexpected problems were in service management
A few surprises appeared after the website had switched successfully.
**First, disabling an old service is not the same as proving its processes are gone.** The old `php-fpm.service` showed a failed state, yet its original master process still held `/tmp/php-cgi.sock`. I verified the process executable and configuration path, then sent the old master a graceful `SIGQUIT` after confirming no active Nginx upstream referenced the old socket. Only then did the old listener disappear.
**Second, the LNMP.org control script knew nothing about `/opt/php-8.3.36`.** It still used the original init script and paths under `/usr/local/php`. More concerning, its generic kill operation contained:
“`bash
killall php-fpm
“`
On a machine temporarily running two PHP-FPM versions, that broad command is dangerous. I patched the management entry point to send PHP-FPM-specific start, stop, reload, restart, and status operations to `php-fpm-8.3.36.service`. I also blocked potentially disruptive aggregate LNMP operations until they could be reviewed safely. MySQL and Nginx continued to run independently.
The original LNMP script was backed up first. This local adaptation must be reviewed again if LNMP.org itself is upgraded.
**Third, the command-line PHP stayed behind.** Even when the website was on PHP 8.3.36, `/usr/bin/php` still resolved to `/usr/local/php/bin/php`, which was PHP 8.3.25. I switched the CLI symlink separately and checked the result:
“`bash
readlink -f /usr/bin/php
php -v
“`
For production systems, it is worth checking cron jobs and scripts for explicit references to an old interpreter. The browser-facing PHP version and the version used by maintenance tasks are separate concerns.
## WP-CLI: the CLI-only configuration fix
After the CLI switch, WP-CLI 2.12.0 initially failed with an error that `proc_open()` or `proc_close()` was unavailable. The PHP `disable_functions` setting included `proc_open`.
I did **not** remove that restriction from the website’s PHP-FPM configuration. Instead, I created a dedicated `wp83` launcher that removes `proc_open` from the disabled-function list only for that CLI invocation. `wp83 –info` then worked and identified PHP 8.3.36. A `core version` check returned WordPress 6.8.11.
One other WordPress configuration issue surfaced during a database-backed WP-CLI command: the site’s `wp-config.php` used a relative include for `wp-salt.php`, and WP-CLI couldn’t find it from the current working directory. That is a separate path-resolution issue, not evidence of a failed PHP upgrade. In such setups, invoking WP-CLI from the WordPress installation directory or using a location anchored to `__DIR__` in the configuration is safer. As always, never print the contents of your WordPress salt file during diagnosis.
## Stage 4: successful cutover does not mean fast PHP
Immediately after the upgrade, FPM’s three-second slow-request threshold flagged several operations. The traces led into WordPress and plugin code, including WooCommerce asynchronous work and Avada/Fusion Builder rendering. One sample was inside a MySQL query used while resolving attachment information.
The pool also logged:
“`text
server reached max_children setting (4), consider raising it
“`
At that point, PHP-FPM’s systemd memory accounting reached roughly **490 MiB**, while the server still had around **639 MiB available RAM** and about **900 MiB of swap in use**. These are observations, not a diagnosis of persistent memory pressure. A prior short `vmstat` sample showed little sustained swapping.
Raising `pm.max_children` blindly on a 2 GiB host could simply turn a queueing problem into memory pressure. Before changing concurrency, I would examine request rates, the slow-log call stacks, WooCommerce background activity, object caching, and database latency. Multiple WordPress sites sharing the same FPM pool also compete for the same four workers.
## The checklist I would reuse
1. Back up the old PHP installation and Nginx configuration, and test the rollback files.
2. Build or install the new PHP in a **separate prefix**; preserve the old runtime.
3. Mirror required extensions and configuration, and verify the new FPM pool on a **different socket**.
4. Confirm the Nginx worker account can access the new socket; use restrictive permissions.
5. Test PHP through **FastCGI**, not just the command-line binary.
6. Switch the Nginx upstream, run `nginx -t`, and use a graceful reload.
7. Check real WordPress functionality before retiring the old FPM.
8. Review systemd dependencies, LNMP.org management scripts, CLI symlinks, and scheduled tasks independently.
9. Keep an emergency rollback path and investigate performance in a separate change window.
10. Test service recovery after a reboot during a planned maintenance window. I had configured startup ordering, but had **not yet completed a full reboot test** when documenting this migration.
## Final thoughts
The most valuable part of this upgrade was not the PHP compilation. It was **separating preparation from traffic switching**. Having two independent PHP-FPM services made it possible to test first, cut over with a small Nginx change, and keep a quick rollback option.
It also exposed a lesson that applies well beyond PHP: an application is only as maintainable as the scripts surrounding it. The web server, background jobs, startup units, control scripts, and CLI tools all need to agree on which version is actually in production.
In the observed tests, the upgrade reached PHP 8.3.36 without a failed login-page check or a required MySQL restart. The remaining work—performance tuning and a full reboot rehearsal—is best handled as a separate, measured maintenance task rather than folded into the security patch itself.

Leave a Reply