Three ways to write FROM, three behaviours
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>
Renovate: the digest moves through a PR
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"]
}
}
The base php.ini ships with the image
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
A CI gate that refuses a vulnerable PHP
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);
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
The traps
- 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.