# Update the PHP Docker Image Security Patch, No Downtime

> Move a Dockerized PHP app from 8.4.26 to the 8.4.27 security release with zero failed requests, pin the image by digest, and see a bad update get refused.

Canonical URL: https://levelrail.com/blog/ship-php-security-update-docker-zero-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 tiny 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 was fixed

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](https://www.php.net/releases/8_4_27.php) and the [php.net security advisory](https://www.php.net/archive/2026.php#2026-10-21-5) are dated 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](https://www.cve.org/CVERecord?id=CVE-2026-109618) (GitHub advisory [GHSA-w8hw-w9fx-j79w](https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w), out-of-memory handling) and [CVE-2026-109619](https://www.cve.org/CVERecord?id=CVE-2026-109619) (GitHub advisory [GHSA-6wmf-4rv5-p3rp](https://github.com/php/php-src/security/advisories/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](https://www.php.net/archive/2026.php#2026-10-23-1) 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:

```bash
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)")'
```

```text
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](https://levelrail.com/blog/install-levelrail-five-minutes/).
- 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:

```bash
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'])"
```

```text
8.4.27-apache sha256:c6527b06c772a2c3cae648ce58c23a2a12ed2fed41cdfb0619ea64be008cc601 2026-10-23T18:58:05.440899Z
```

On the day we checked, `8.4.27-apache` was pushed 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, it is 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
<?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:

```dockerfile
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:

```bash
docker pull php:8.4.27-apache
docker inspect --format '{{index .RepoDigests 0}}' php:8.4.27-apache
```

```text
php@sha256:c6527b06c772a2c3cae648ce58c23a2a12ed2fed41cdfb0619ea64be008cc601
```

The digest matches the one Docker Hub returned in step 1. Build with it:

```bash
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:

```yaml
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
```

```bash
levelrail-cli apps create --file app.yaml
levelrail-cli apps deploys wait phpapp
```

```text
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:

```bash
while true; do printf '%s ' "$(date +%T)"; curl -s -w ' %{http_code}\n' http://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:

```bash
curl -s http://phpapp.localhost:8081/
docker exec $(docker ps --format '{{.Names}}' | grep phpapp | head -1) php -v | head -1
```

```text
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

```bash
levelrail-cli apps deploy phpapp --image lrphp/phpapp:2-php8.4.27
levelrail-cli apps deploys wait phpapp
```

```text
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:

```text
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.

![The app overview in the dashboard showing Healthy and the 2-php8.4.27 release serving](https://levelrail.com/blog/ship-php-security-update-docker-zero-downtime/app-serving-patched.webp "The overview after the update: Healthy, serving the 8.4.27 build.")

## 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`:

```bash
levelrail-cli apps deploy phpapp --image lrphp/phpapp:3-php8.4.27-badini
levelrail-cli apps deploys wait phpapp
```

```text
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.

![The deploy history with the failed bad-ini row, its readiness error, and the patched release still Current](https://levelrail.com/blog/ship-php-security-update-docker-zero-downtime/deploys-readiness-refused.webp "The refused deploy keeps its reason in the history.")

## Step 7: Roll back

The gate already kept the good release serving, so the rollback here only re-points the app at it:

```bash
levelrail-cli apps rollback phpapp
levelrail-cli apps deploys wait phpapp
levelrail-cli apps status phpapp
```

```text
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:

```text
[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, was answered by 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 were answered by 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 is meant.
- 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](https://levelrail.com/blog/rollback-failed-deploy-docker/).

## 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 <container> 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.
