Europe/Paris
Posts

PHP Docker images: the patch doesn't exist until you rebuild

October 22, 2026 · 3 min de lecture
On October 21, around 17:40 Paris time, the php:8.5.12, 8.4.27, 8.3.36 and 8.2.35 tags were published on Docker Hub, a few hours after the PHP2Shell fixes. Not one running container changed. In a containerized world, a PHP patch is a rebuild followed by a redeploy, nothing else.
Dockerfile
# Floating tag: follows patches... on the next build, and only with --pull
FROM php:8.3-fpm-bookworm

# Frozen tag: will never move by itself
FROM php:8.3.35-fpm-bookworm

# Digest: reproducible, moves only when someone updates the digest
FROM php:8.3-fpm-bookworm@sha256:<digest>
The floating tag gives a false sense of safety. Without --pull, Docker happily reuses the base image already present on the machine, and a CI runner with a warm cache can rebuild "fresh" images on a two-month-old base for a long time. The frozen tag is honest: it moves only through a commit. The digest is the most reproducible, but nobody updates a SHA by hand. Both only make sense with a bot.
Json
{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended", "docker:pinDigests"],
  "packageRules": [
    {
      "matchDatasources": ["docker"],
      "matchPackageNames": ["php", "dunglas/frankenphp"],
      "matchUpdateTypes": ["patch", "digest"],
      "automerge": true
    }
  ],
  "vulnerabilityAlerts": {
    "enabled": true,
    "labels": ["security"]
  }
}
docker:pinDigests turns every FROM into tag@sha256, and Renovate opens a PR as soon as the digest behind the tag changes. Automerge only applies to patches and digests: a jump from 8.3 to 8.4 still goes through a human.
Dockerfile
FROM php:8.3-fpm-bookworm@sha256:<digest>

RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/php/zz-hardening.ini "$PHP_INI_DIR/conf.d/"
Ini
; docker/php/zz-hardening.ini
expose_php = Off
session.upload_progress.enabled = Off
post_max_size = 8M
upload_max_filesize = 8M
max_input_vars = 1000
max_input_nesting_level = 16
session.upload_progress.enabled = Off is the setting the PHP advisory recommends keeping even after patching. In the official image, conf.d is shared by the CLI and FPM, which makes the CI check below meaningful. A small script run inside the built image. It fails the job if the PHP branch is no longer maintained, if the patch version is below the fix, or if upload progress is still on.
Php
<?php
// ci/assert-php-patched.php
declare(strict_types=1);

/** @var array<string, string> $minimums */
$minimums = [
    '8.2' => '8.2.35',
    '8.3' => '8.3.36',
    '8.4' => '8.4.27',
    '8.5' => '8.5.12',
];

$branch = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;

if (!array_key_exists($branch, $minimums)) {
    fwrite(STDERR, sprintf("PHP %s is no longer maintained: no official fix.\n", PHP_VERSION));
    exit(1);
}

if (version_compare(PHP_VERSION, $minimums[$branch], '<')) {
    fwrite(STDERR, sprintf(
        "PHP %s is vulnerable to CVE-2026-109618/109619, minimum is %s.\n",
        PHP_VERSION,
        $minimums[$branch],
    ));
    exit(1);
}

if (filter_var(ini_get('session.upload_progress.enabled'), FILTER_VALIDATE_BOOL)) {
    fwrite(STDERR, "session.upload_progress.enabled is still on.\n");
    exit(1);
}

echo sprintf("OK: PHP %s\n", PHP_VERSION);
And the GitHub Actions workflow that goes with it, including a weekly scheduled rebuild even when nobody commits:
Yaml
name: image

on:
  push:
    branches: [main]
  schedule:
    - cron: '17 4 * * 1'
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build
        uses: docker/build-push-action@v6
        with:
          context: .
          pull: true
          load: true
          tags: app:ci
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Is PHP patched?
        run: docker run --rm -v "$PWD/ci:/ci:ro" app:ci php /ci/assert-php-patched.php

      - name: Push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
pull: true is the --pull flag. The second build is almost free: everything comes out of the cache that the first one just filled. On the server side, the image is still not running. docker compose pull && docker compose up -d, or a rollout on Kubernetes. And images derived from someone else's (wordpress, an internal base image) are only fixed once their own maintainer has rebuilt them.
  • The version script lies with distribution packages. Debian and Ubuntu backport the fix without bumping PHP_VERSION: an apt-based image will fail the gate while being patched. There, check the package version, or let a scanner like trivy image --severity CRITICAL --exit-code 1 do the job once its database knows the CVE.
  • Automerge without tests ships a broken image. Only keep it if the CI actually runs the test suite against the rebuilt image, not just a php -v.
  • A gate with hard-coded minimums ages badly. The next security release makes it lenient again without anyone noticing. Either update it at each advisory, or replace it with the scanner.
The scheduled weekly rebuild is what turns "we'll patch on the next deploy" into a delay capped at seven days. Without it, an app that nobody touches stays on the base image of its last commit.
On this page
Book a call