Update the PHP Docker Image Security Patch, No Downtime
A security release is the one deploy you do not want to delay and also do not want to break. The patch should reach production fast, and your users should not notice it. This guide moves a small PHP app from php:8.4.26-
A security release is the one deploy you do not want to delay and also do not want to break. The patch should reach production fast, and your users should not notice it.
This guide moves a small PHP app from php:8.4.26-apache to php:8.4.27-apache with a request loop running the whole time, then ships a deliberately broken update to show the readiness gate refusing it. Every command and output below comes from a real run on 2026-10-24.
What the patch fixes
This section only restates what the primary sources say. Read them yourself before you act.
- php.net published security releases PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35. The PHP 8.4.27 release page and the php.net security advisory carry the date 21 Oct 2026.
- The advisory says the releases fix "two critical vulnerabilities that can allow unauthenticated remote code execution" and that "There are no complete mitigations, updating is the only safe choice."
- The two CVEs are CVE-2026-109618 (GitHub advisory GHSA-w8hw-w9fx-j79w, out-of-memory handling) and CVE-2026-109619 (GitHub advisory GHSA-6wmf-4rv5-p3rp, stale global state).
- On exposure, the advisory says: "We consider the risk highest for mod_php, with a demonstrated exploit where an unauthenticated request can trigger the attack before any application code runs."
- A separate informational advisory of 23 Oct 2026 suggests setting
session.upload_progress.enabled=0until the next regular bugfix release for CVE-2026-115560, which it says needs an unusual combination of configuration and application code.
You can read the CVE record yourself instead of trusting a summary:
curl -s https://cveawg.mitre.org/api/cve/CVE-2026-109618 | jq -r '.cveMetadata.cveId, (.containers.cna.affected[0].versions[] | "\(.version) fixed in \(.lessThan)"), (.containers.cna.metrics[0].cvssV4_0 | "CVSS 4.0 \(.baseScore) \(.baseSeverity)")'
CVE-2026-109618
8.2.* fixed in 8.2.35
8.3.* fixed in 8.3.36
8.4.* fixed in 8.4.27
8.5.* fixed in 8.5.12
CVSS 4.0 9.2 CRITICAL
CVE-2026-109619 lists the same fixed versions and the same score in its record. This guide does not say whether anyone is exploiting these bugs in the wild, because none of the sources above say so.
What you need
- A running Levelrail instance. If you do not have one yet, follow the five minute install.
- Docker, and
levelrail-clilogged in to your instance. - A PHP app with a health route. Ours is one file.
Step 1: Confirm the patched tag exists
Do not assume a version number on a news page means an image is ready. Ask the registry. Docker Hub's API returns the digest and push time for the official php image:
curl -s 'https://hub.docker.com/v2/repositories/library/php/tags/8.4.27-apache' | python3 -c "import sys,json;d=json.load(sys.stdin);print(d['name'],d['digest'],d['last_updated'])"
8.4.27-apache sha256:c6527b06c772a2c3cae648ce58c23a2a12ed2fed41cdfb0619ea64be008cc601 2026-10-23T18:58:05.440899Z
On the day we checked, 8.4.27-apache appeared on 2026-10-23, and 8.3.36-apache and 8.2.35-apache on 2026-10-24. If your tag is not there yet, wait. The old tag still works, just unpatched.
Step 2: The app and a pinned Dockerfile
The whole app is index.php. The root route prints the PHP version and a build tag. /healthz returns 200 only if the functions the app needs exist:
<?php
$path = strtok($_SERVER['REQUEST_URI'], '?');
if ($path === '/healthz') {
// Readiness: the app only counts as healthy if the functions it needs exist.
$ok = function_exists('parse_url') && function_exists('json_encode');
http_response_code($ok ? 200 : 500);
echo $ok ? "ok\n" : "missing required function\n";
exit;
}
header('Content-Type: text/plain');
echo 'php ' . PHP_VERSION . ' build ' . (getenv('BUILD_TAG') ?: 'unknown') . "\n";
The Dockerfile takes the full base reference as a build argument, so the digest travels with the build:
ARG BASE=php:8.4.27-apache
FROM ${BASE}
ARG BUILD_TAG=unknown
ENV BUILD_TAG=$BUILD_TAG
RUN echo 'FallbackResource /index.php' > /etc/apache2/conf-enabled/fallback.conf
COPY index.php /var/www/html/index.php
Pull the tag, then read the digest Docker recorded. This is how you pin by digest:
docker pull php:8.4.27-apache
docker inspect --format '{{index .RepoDigests 0}}' php:8.4.27-apache
php@sha256:c6527b06c772a2c3cae648ce58c23a2a12ed2fed41cdfb0619ea64be008cc601
The digest matches the one Docker Hub returned in step 1. Build with it:
docker build --build-arg BASE=php:8.4.27-apache@sha256:c6527b06c772a2c3cae648ce58c23a2a12ed2fed41cdfb0619ea64be008cc601 --build-arg BUILD_TAG=2 -t lrphp/phpapp:2-php8.4.27 app
A tag like 8.4.27-apache can be rebuilt later. A digest cannot change under you. We built the old image the same way from php:8.4.26-apache@sha256:60111400ff77....
Step 3: Create the app on the old version
The manifest sets the readiness check at create time, which flags alone cannot do:
version: 1
services:
phpapp:
build:
type: image
image: lrphp/phpapp:1-php8.4.26
port: 80
health:
readiness: { path: /healthz, interval: 2s, timeout: 2s }
readyTimeout: 20s
levelrail-cli apps create --file app.yaml
levelrail-cli apps deploys wait phpapp
app "phpapp" created
name: phpapp
image: lrphp/phpapp:1-php8.4.26@sha256:bd12df3e87271fc212218411e1b39f8583b752745c43db1d47464543d1fbeb9a
port: 80
auto url: http://phpapp.localhost:8081
readiness: GET http://:port/healthz (expect 200-299) every 2s, timeout 2s
liveness: not configured
ready timeout: 20s
deploy dep_PrzqVIKS4nzJ of "phpapp": healthy
Start a request loop that records the status and the body once every 200 ms. We used a small Python script, but any loop works:
while true; do printf '%s ' "$(date +%T)"; curl -s -w ' %{http_code}\n' phpapp.localhost:8081/; sleep 0.2; done
Step 4: Check the running version
The first answer comes from the app, the second from inside the container:
curl -s phpapp.localhost:8081/
docker exec $(docker ps --format '{{.Names}}' | grep phpapp | head -1) php -v | head -1
php 8.4.26 build 1
PHP 8.4.26 (cli) (built: Oct 21 2026 00:32:44) (NTS)
That is the unpatched version, and the thing we are about to fix.
Step 5: Ship the patched image
levelrail-cli apps deploy phpapp --image lrphp/phpapp:2-php8.4.27
levelrail-cli apps deploys wait phpapp
app "phpapp" now targets image "lrphp/phpapp:2-php8.4.27@sha256:12206a41e8a7f852fa30217ec8248b8219f4a0a9ac82ddcf06cc345fcf0ff316"; reconcile is asynchronous, check "levelrail-cli apps status phpapp"
deploy dep_L1b6sXj-msTK of "phpapp": healthy
deploys wait returned exit code 0 after 12 seconds. Now check the version again, from both sides:
php 8.4.27 build 2
PHP 8.4.27 (cli) (built: Oct 21 2026 15:58:11) (NTS)
The loop flipped from 8.4.26 to 8.4.27 at 15:20:22.080. Across the whole run it sent 1028 requests and every one returned 200. The loop never saw a connection error.
Step 6: Ship a bad update on purpose
Updates fail for boring reasons. Here a hardening change to php.ini disables a function the app needs. We built the same patched base with one extra line, disable_functions=parse_url in a conf.d file, and tagged it 3-php8.4.27-badini:
levelrail-cli apps deploy phpapp --image lrphp/phpapp:3-php8.4.27-badini
levelrail-cli apps deploys wait phpapp
deploy dep_Wlv-IHpirkl4 of "phpapp": failed
failure: health_check_failed (retryable: false)
cause: The container started but never passed its readiness health check.
fix: Confirm health.readiness.path returns a success status quickly, or raise health.readyTimeout if the app just starts slowly.
docs: /deploy-failures#health_check_failed
log excerpt:
readiness probe: never became ready (context deadline exceeded), last attempt: GET http://127.0.0.1:58366/healthz returned 500, expected 200-299
ReadinessFailed
The command exited with code 7 after 32 seconds. The new container never received traffic. In the loop, no request returned anything but 200 and none came from the bad build, so users stayed on 8.4.27 build 2.
Step 7: Roll back
The gate already kept the good release serving, so the rollback here only re-points the app at it:
levelrail-cli apps rollback phpapp
levelrail-cli apps deploys wait phpapp
levelrail-cli apps status phpapp
rolling back to the previous successful deploy, image lrphp/phpapp:2-php8.4.27@sha256:12206a41e8a7f852fa30217ec8248b8219f4a0a9ac82ddcf06cc345fcf0ff316
deploy dep_KlD5PsX6LZEE of "phpapp": healthy
TYPE STATUS REASON MESSAGE LAST TRANSITION
EgressPolicyReady Unknown NotConfigured 2026-10-24T20:21:35Z
Ready True AlreadyRunning 2026-10-24T20:21:35Z
previous release held until 2026-10-24 15:25:11 (instant rollback target)
It finished in 4 seconds. We then sampled the served version every 30 seconds for two more minutes:
[15:22:06] served=php 8.4.27 build 2
[15:22:36] served=php 8.4.27 build 2
[15:23:06] served=php 8.4.27 build 2
[15:23:36] served=php 8.4.27 build 2
If you would rather return to the old version, apps rollback goes to the previous successful deploy. Do that only when the patched release itself misbehaves, since the old one is the unpatched one.
What to know before you rely on it
We found rough edges while recording this guide:
- Exactly one request, at 15:21:35.055 as the rollback finished, got an answer from 8.4.26 build 1 (status 200) before traffic returned to 8.4.27. In an earlier identical run, 3 requests over 0.6 seconds got answers from 8.4.26 about 27 seconds after the rollback, and
deploys waitright after the rollback reported a failed deploy (exit 7) from the bad target before a later reconcile settled. For a security update that is worth knowing: a rollback can briefly serve an older, unpatched release. - The readiness gate only protects you as far as your health route goes. Ours checked that two functions exist, so it caught this failure. A route that always returns 200 would have let the bad build through.
- php.net's ChangeLog lists 8.4.27 under 22 Oct 2026 while its release page and announcement say 21 Oct 2026. We did not resolve which date they mean.
- We tested the
-apachevariant on one architecture. We did not test-fpm,-alpine, 8.3, 8.2 or 8.5 images.
Recap
- Read the primary sources: php.net, the CVE records and the GitHub advisories. Confirm the patched tag exists on Docker Hub before you plan the rollout.
- Pull the patched tag and pin your base image by digest.
- Create the app from a manifest with a real readiness check, and verify the running version with
docker execor the app itself. - Ship with
apps deployand gate onapps deploys wait. A failed readiness check never gets traffic. - Keep
apps rollbackin reach, and remember the old release is the unpatched one.
For the rollback mechanics in depth, read Roll back a failed Docker deploy.
Frequently asked questions
Which PHP versions fix CVE-2026-109618 and CVE-2026-109619?
According to php.net and the CVE records, the fixes are in PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35. PHP 8.1 and older no longer receive updates from the PHP project.
How do I check which PHP version a running container uses?
Run docker exec php -v, or have the app print PHP_VERSION on a route. The tag in your Dockerfile is not proof, because a tag can be rebuilt. The running version is.
How do I make a PHP base image build reproducible?
Pin the base image by digest, for example php:8.4.27-apache@sha256:c6527b06c772..., and read the digest from docker inspect after you pull the tag.
Originally published at https://levelrail.com/blog/ship-php-security-update-docker-zero-downtime/
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.
