Patching PHP-FPM with Source-Built PHP 8.3.36 Without Waiting for Remi
Contents
TL;DR
Built files Compiled only php, php-fpm, and opcache.so from official PHP 8.3.36 source to resolve php2shell
Zero downtime Replaced binaries in running containers and reloaded PHP-FPM gracefully using USR2 signals
Signal routing Sent USR2 directly to the PHP-FPM master PID because docker kill only reaches PID 1 (Apache)
Persistence Excluded PHP in dnf.conf and built a persistent image to survive container recreations
In the previous article, I covered the release of PHP 8.3.36 addressing the php2shell vulnerability at 23:00 JST on October 21, 2026, and noted that Remi’s repository would not update that night.
A server environment I manage happened to run PHP 8.3 from Remi.
Waiting for updated RPM packages would delay patching until the next day or later, so I built the binaries directly from the official PHP source code.
Upstream Release Status Right After Launch
Before the release, no official fixed versions were available.
Remi’s newest version was 8.3.35, which did not include patches for the two CVEs.
When PHP 8.3.36 was published at 23:00, the official PHP announcement stated that Remi would not update on release night.
Remi’s repository remained at 8.3.35, and AlmaLinux AppStream showed no advisories for either CVE.
Rather than running release candidates (RC), I switched to compiling the official stable release from source.
Container Architecture in Staging and Production
The environment runs on AlmaLinux 9 inside Docker containers, using PHP 8.3 installed from Remi’s repository.
Apache runs as PID 1 (the main container process) and forwards requests to PHP-FPM.
PHP-FPM runs a single master process managing multiple worker processes.
flowchart TD
A["docker kill --signal"] --> B["PID 1: Apache"]
B -->|Forwards requests| C["PHP-FPM master"]
C --> D["worker"]
C --> E["worker"]
Staging and production use identical container structures.
I validated the workflow in staging before placing the compiled binaries into production.
Compiling Inside a Dedicated Container
I started a dedicated build container on the staging server without exposed ports or database links.
It used the same base AlmaLinux 9 image as the running containers.
I left the running PHP environment untouched until the newly built binaries passed verification.
I only replaced three files: php (CLI), php-fpm, and opcache.so.
Remi’s PHP splits functionality between modules statically compiled into the core binary and shared extensions (.so files) loaded from /usr/lib64/php/modules.
Because existing shared extensions would stay in place, the new core binary had to match the API version (20230831 for PHP 8.3) and the non-thread-safe (NTS) build specification.
OPcache is included in the main PHP source tree, so I built it alongside PHP 8.3.36.
I tailored ./configure options to match the static modules shown by php -n -m (listing modules without loading php.ini).
I passed --disable-all to strip out default extensions, then re-enabled only the modules present in Remi’s static build.
Config paths, extension directories, and the FPM execution user and group were aligned with the existing setup.
The build script executed inside the dedicated build container:
#!/bin/bash
set -euo pipefail
exec > /build/build-output.txt 2>&1
printf 'BUILD_STARTED '; date -u
php -n -m > /build/static-modules-before.txt
php -m > /build/modules-before.txt
dnf -y --setopt=install_weak_deps=False --setopt=timeout=20 --setopt=retries=1 install gcc gcc-c++ make autoconf bison re2c pkgconf-pkg-config libxml2-devel openssl-devel readline-devel zlib-devel libacl-devel systemd-devel xz tar
cd /build
curl --fail --location --retry 2 --connect-timeout 15 --max-time 120 --output php-8.3.36.tar.xz https://www.php.net/distributions/php-8.3.36.tar.xz
printf '%s %s\n' dd54693aa334c5c4d72e9aee96b082c2473750a18a6c1d105789c93a3b1692fd php-8.3.36.tar.xz | sha256sum -c -
tar -xf php-8.3.36.tar.xz
cd php-8.3.36
export EXTENSION_DIR=/usr/lib64/php/modules
./configure --prefix=/usr --bindir=/usr/bin --sbindir=/usr/sbin --libdir=/usr/lib64 --sysconfdir=/etc --localstatedir=/var --with-libdir=lib64 --with-config-file-path=/etc --with-config-file-scan-dir=/etc/php.d --disable-all --enable-cli --enable-fpm --disable-cgi --disable-phpdbg --enable-filter --with-libxml --with-openssl --enable-pcntl --with-readline --enable-session --enable-sockets --with-zlib --enable-opcache=shared --with-fpm-user=apache --with-fpm-group=apache --with-fpm-acl --with-fpm-systemd
make -j4
mkdir -p /build/runtime
cp -p sapi/cli/php /build/runtime/php
cp -p sapi/fpm/php-fpm /build/runtime/php-fpm
cp -p modules/opcache.so /build/runtime/opcache.so
/build/runtime/php -n -v
/build/runtime/php -n -r 'if(PHP_VERSION!=="8.3.36"||ini_get("extension_dir")!=="/usr/lib64/php/modules"){exit(1);}'
/build/runtime/php-fpm -t
sha256sum /build/runtime/php /build/runtime/php-fpm /build/runtime/opcache.so > /build/runtime/SHA256SUMS
printf 'BUILD_COMPLETE '; date -u
I verified the downloaded tar.xz against the SHA-256 published on php.net before unpacking.
The build finished in just over three minutes.
I recorded the SHA-256 hashes of the three generated runtime files to confirm that identical binaries were deployed across staging and production.
Next, I loaded all existing shared extensions using the new CLI php and compared the output against the pre-build php -m list.
All modules loaded without missing dependencies or startup warnings.
Replacing Binaries in Running Containers
Before copying the new files into place, I backed up the originals inside the container.
| Binary | In-Container Path |
|---|---|
php | /usr/bin/php |
php-fpm | /usr/sbin/php-fpm |
opcache.so | /usr/lib64/php/modules/opcache.so |
Overwriting these files on disk does not immediately update active worker processes; running FPM workers continue using the older code in memory.
Zero-Downtime Reload via USR2 Signal
Sending a USR2 signal to the PHP-FPM master process initiates a graceful reload. It cycles worker processes and re-reads both configuration and binary files.
The PHP-FPM manual documents this behavior as “graceful reload of all workers + reload of fpm conf/binary.”
This reloads the updated php-fpm binary without stopping the container.
Using docker kill --signal USR2 does not work as expected here.
According to Docker documentation, docker kill delivers signals strictly to PID 1, which in this container is Apache.
To target PHP-FPM, I read the master PID from /run/php-fpm/php-fpm.pid inside the container and delivered the signal directly.
Before signaling, I ran configuration syntax checks with php-fpm -t and httpd -t.
docker exec <container> php-fpm -t
docker exec <container> httpd -t
docker exec <container> sh -c 'kill -USR2 "$(cat /run/php-fpm/php-fpm.pid)"'
After signaling, I tested the application API to confirm the reload.
Running php -v only inspects the CLI binary and cannot confirm whether running FPM workers were replaced.
The API returned HTTP 200 with JSON, and the X-Powered-By response header reported PHP/8.3.36.
Because revealing version headers externally is bad practice, I removed it once verified.
I set expose_php=Off in php.ini and sent another USR2 signal to the master process.
Because expose_php cannot be modified dynamically via ini_set(), a master reload is necessary.
I confirmed that the header disappeared and the API continued responding normally.
Testing in Staging
In staging, I tested both USR2 reloading and a full container restart on the pre-patched binaries to see if in-flight requests dropped.
During USR2 reloading, 21 consecutive API requests succeeded with zero failures.
In contrast, a container restart dropped exactly one connection before recovering.
After swapping in PHP 8.3.36 and sending USR2, I made 12 consecutive API requests. All returned HTTP 200 with JSON and X-Powered-By: PHP/8.3.36.
Restarting the container took roughly 1.6 seconds, after which Apache and FPM booted cleanly and served the same API.
Applying Changes to Production
In production, I verified the three files matched the exact SHA-256 hashes from staging before replacing them.
I reloaded the production container using USR2 without restarting the container.
A few minor snags popped up during deployment.
Remote command quoting failed over SSH, which I corrected before dispatching the USR2 signal. Mount ordering in container inspection appeared different from baseline, requiring a quick diff against Docker Compose bind mount definitions to confirm parity. In staging, the compose file lacked write permissions under the current user, so I mounted it into a temporary container to apply the edits while preserving host ownership.
Continuous automated polling showed 36 requests across the reload window with zero failures.
The API returned HTTP 200 with JSON, and once X-Powered-By updated to PHP/8.3.36, I applied expose_php=Off to strip the header.
No stale FPM worker processes remained, and the container ID, image, uptime, and mounts remained entirely undisturbed.
I confirmed healthy responses from the patched PHP-FPM in production about 20 minutes after PHP’s official release.
RPM Version Discrepancies and Package Manager Pinning
Because the files were placed manually over existing paths, rpm -q still reports the older package version.
Because the package manager cannot reflect these manual binary replacements, I recorded the active version string and SHA-256 sums directly inside the container filesystem.
Running dnf upgrade in this state would overwrite the custom binary with an unpatched Remi RPM.
After backing up dnf.conf, I added exclude=php* to prevent automated updates from replacing the compiled files.
Creating Persistent Container Images
Because this patching method only modifies the running container layer, recreating the container would revert it to the base image.
I committed the pre-patch container state, added the three compiled binaries along with the modified dnf.conf, and built a persistent image.
In Docker Compose, I pointed the PHP service to this local image tag and verified the configuration with docker compose config.
I kept running production containers untouched during this maintenance window.
To verify container creation, I launched an isolated test container from the new image without exposed ports, confirmed clean startup, and then removed it.
Once official RPM updates land in Remi’s repository, I will revert these custom binaries back to clean package management. But for late-night emergency response, compiling the stable release and executing a zero-downtime USR2 reload proved to be a practical and reliable workaround.