DEV Community

Cover image for Update the PHP Docker Image Security Patch, No Downtime
GDS K S
GDS K S

Posted on Originally published at levelrail.com

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.

Part 5 of 7 in the Deploy and operate Levelrail series: 1. Install a Self-Hosted PaaS on Your Own Server in Five Minutes; 2. Roll Back a Failed Docker Deploy Without Downtime; 3. Restart a Docker Container Without Downtime; 4. Node Graceful Shutdown in Docker: Handle SIGTERM on Deploy; 5. Update the PHP Docker Image Security Patch, No Downtime (this post); 6. Docker Container Restarting in a Loop: 5 Causes, Fixed; 7. Why a Failed Health Check Still Went Live.

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=0 until 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)")'
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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-cli logged 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'])"
Enter fullscreen mode Exit fullscreen mode
8.4.27-apache sha256:c6527b06c772a2c3cae648ce58c23a2a12ed2fed41cdfb0619ea64be008cc601 2026-10-23T18:58:05.440899Z
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
php@sha256:c6527b06c772a2c3cae648ce58c23a2a12ed2fed41cdfb0619ea64be008cc601
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
levelrail-cli apps create --file app.yaml
levelrail-cli apps deploys wait phpapp
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
php 8.4.26 build 1
PHP 8.4.26 (cli) (built: Oct 21 2026 00:32:44) (NTS)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

The app overview in the dashboard showing Healthy and the 2-php8.4.27 release serving

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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.

The deploy history with the failed bad-ini row, its readiness error, and the patched release still Current

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
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 wait right 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 -apache variant on one architecture. We did not test -fpm, -alpine, 8.3, 8.2 or 8.5 images.

Recap

  1. 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.
  2. Pull the patched tag and pin your base image by digest.
  3. Create the app from a manifest with a real readiness check, and verify the running version with docker exec or the app itself.
  4. Ship with apps deploy and gate on apps deploys wait. A failed readiness check never gets traffic.
  5. Keep apps rollback in 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.

Previous: Node Graceful Shutdown in Docker: Handle SIGTERM on Deploy

Next in the series: Docker Container Restarting in a Loop: 5 Causes, Fixed

All parts: Deploy and operate Levelrail


Originally published at https://levelrail.com/blog/ship-php-security-update-docker-zero-downtime/

Top comments (0)