Skip to main content

A Green Deploy That Ships the Old PHP

A critical PHP release, an unpinned install that should have picked it up, and the one line in a deploy log that showed my builds would quietly keep serving the vulnerable version.

Updated 11 min readMarkdown

On 21 October 2026, PHP shipped 8.5.12, 8.4.27, 8.3.36 and 8.2.35 to fix two critical vulnerabilities that can let an unauthenticated request run code on the server. The php.net advisory put it bluntly: all PHP users are strongly encouraged to update immediately. A notice from Securing Laravel had warned that the release was scheduled for 14:00 UTC that day.

Akluma, the savings app I build with Laravel, runs PHP 8.5 on Fly.io, so it was in scope. My first thought was that the fix would be almost free. The Dockerfile installs PHP from a package archive without naming a version, so the next deploy would install whatever the archive held that day. All I had to do was wait for the patched packages to appear, then deploy.

That plan had a hole in it, and the hole was a single word in a build log.

In short: a Dockerfile that runs apt-get install without a version does not pick up a security patch while Docker keeps that build step cached, and a cached build still deploys green. Adding --no-cache to flyctl deploy makes every deploy install the newest PHP the archive has. On Akluma it added about four minutes per release.

How does PHP get into a Fly.io Laravel image?

Fly's generated Dockerfile installs PHP from the ondrej PPA with apt-get, and it names no version. Akluma's Dockerfile is the one Fly's Laravel launcher created. It starts from ubuntu:24.04, adds the ondrej PPA (the package archive most Ubuntu servers use for current PHP releases), and installs a list of packages from a file in the repository:

ADD .fly/php/packages/${PHP_VERSION}.txt /tmp/php-packages.txt

RUN apt-get update \
    && apt-get install -y --no-install-recommends gnupg2 ca-certificates git-core curl zip unzip \
    ...
    && apt-get -y --no-install-recommends install $(cat /tmp/php-packages.txt) \
    ...

The package list says php8.5-cli, php8.5-fpm and the extensions, with no version numbers. Nothing anywhere in the repository says which PHP 8.5 patch release the app runs. Whatever the PPA serves at build time is what ships.

The PPA published 8.5.12 for Ubuntu 24.04 at 14:45:38 UTC, according to Launchpad's API. From that moment, a fresh build would install the fixed version. The word that matters in that sentence is "fresh".

Does Docker cache the apt-get install step?

Yes. Docker reuses a build step as long as its instruction text and input files are unchanged, and it never checks whether the archive now has a newer package.

Earlier that day, at 12:16 UTC, a routine code change had gone out to staging. Before deploying anything for the patch, I read that run's build log to see what the PHP install step actually did. This is what it said:

#16 [base  5/16] RUN apt-get update     && apt-get install -y --no-install-recommends gnupg2 ...
#16 CACHED

The step that installs PHP never ran. Fly's remote builder found a layer it had built before and reused it, and that run logged 16 CACHED lines in all.

Docker decides whether to reuse a step by looking at what produced it: the instruction's text, and for ADD and COPY, the contents of the files involved. It never asks the network what apt-get install would return today. The instruction above had not changed in months, and neither had the package list or the PPA's signing key, so as far as the cache was concerned, there was nothing new to build. The layer it reused held whatever PHP the PPA had served the last time that step really ran.

I want to be careful about what this log proves. The run predates the release by about two and a half hours, so it shows that the builder keeps this step cached, not that a deploy after 14:45 would have shipped 8.5.11. That second claim is an inference. It is a strong one, because nothing the cache looks at would have changed, but I never ran a deploy without the fix to watch it happen. What I do have is what production reported the next morning, before the fix went out:

$ flyctl ssh console -a akluma-prod -C "php -v"
PHP 8.5.11 (cli) (built: Sep 24 2026 13:49:29) (NTS)

A deploy in that state goes green. The build succeeds, the health checks pass, the site answers, and the machines keep running a PHP version that the advisory says to replace immediately. Nothing in the pipeline would have told me.

Why not pin the PHP version?

The ondrej PPA deletes a superseded version one to two days after the next release, so a pinned version stops installing within days. That ruled out the usual answer.

When I asked how teams normally handle this, the answer was the textbook one: pin the exact version in the repository, let a bot such as Dependabot or Renovate open a pull request when a new one appears, and scan the built image for known vulnerabilities. A pinned version makes every PHP upgrade a reviewed change, and it busts the cache on its own, because the instruction text changes.

It does not fit this setup. The ondrej PPA keeps only the current release of each package. Across 19 versions checked through Launchpad's API, each superseded one had been deleted from the archive one to two days after its successor arrived. A line that says php8.5-fpm=8.5.12-… would install fine this week and fail with "version not found" a day or two after 8.5.13 lands, which means the pin breaks the build on a schedule I do not control.

The other idea on the table was a cache-busting build argument: an ARG holding a date, placed just before the PHP step, with a comment saying "change this date to force a fresh PHP install". It works, and I rejected it, because it depends on someone remembering to bump a date during an incident. That someone is me, and the whole point of this exercise was to stop depending on what I remember.

How do you force a fresh PHP install on Fly.io?

Add --no-cache to flyctl deploy. On Akluma it added about four minutes per release. Both of Akluma's deploy workflows, staging and production, call the same script, and the script's deploy line now reads:

flyctl deploy -c "$CONFIG" --remote-only --no-cache

Every deploy now builds every step from scratch, so every deploy installs the newest PHP the PPA has. Nobody has to remember a flag, a date or a version.

I expected the cost to be small, for a reason that is easy to miss. Both stages of the Dockerfile copy the whole repository early:

COPY . /var/www/html

Any code change invalidates that layer and everything after it, so composer install, npm ci and the Vite build were already running on every code deploy. The cache was only ever saving the system packages and a few config copies, which is exactly the part I now want rebuilt.

The measured cost, on the workflow step that runs the deploy, one run each:

             cached (21 Oct)   uncached (22 Oct)
Staging      2m19s             5m00s
Production   1m22s             2m53s

Four minutes of extra build time per release is a fair price for knowing which PHP is in the image. The rollback path is untouched, because it redeploys the previous image by reference and builds nothing.

How do you check which PHP each Fly machine runs?

Run php -v and php-fpm -v on each process group with flyctl ssh console -g <group>. A green deploy was the thing I had stopped trusting, so the check had to happen in two places. The first was the build log of the uncached staging deploy, where the PHP step now did real work:

Get:5 https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble/main amd64 php8.5-cli amd64 8.5.12-1+ubuntu24.04.1+deb.sury.org+1 [3166 kB]
Get:6 https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble/main amd64 php8.5-fpm amd64 8.5.12-1+ubuntu24.04.1+deb.sury.org+1 [3178 kB]

The only two lines marked CACHED in that run were the Dockerfile syntax image and the ubuntu:24.04 base image, which are fixed references and fine to reuse.

The second was the machines themselves. Akluma runs three on Fly in each environment: app serves web requests, worker runs the queue, and cron runs the scheduler. All three come from the same image, but I checked each one rather than assume it:

flyctl ssh console -a akluma-prod -g app -C "php -v"
flyctl ssh console -a akluma-prod -g app -C "php-fpm -v"
flyctl ssh console -a akluma-prod -g worker -C "php -v"
flyctl ssh console -a akluma-prod -g cron -C "php -v"

Every one printed PHP 8.5.12, on staging and then on production, and so did php-fpm -v on the worker and cron machines.

One small trap is worth passing on. To see whether the PPA had the fix, I opened Launchpad's package page filtered by php8.5-fpm, and it said there was no matching package. The page filters by source package name, not by the binary you install, so the filter that works is php8.5.

What doesn't --no-cache solve?

It doesn't tell you a patch exists, and it can't install a fix your package source hasn't published yet.

--no-cache makes sure that a deploy picks up the patch. It does nothing to make a deploy happen. I learned about this release from a newsletter, and the next patch may not come with a newsletter. The PPA has shipped a PHP 8.5 patch roughly once a month since June, and something needs to tell me when one lands. I have written up the options (a small scheduled workflow that compares the PPA's latest release with the last production deploy, an image scan, or moving to the official PHP image so a bot can open the bump) and have not picked one yet.

The other gap showed up the next morning, on my own laptop. I rebuilt the local Sail image without the cache, expecting it to match production, and it installed 8.5.11 again. Sail installs PHP from a different archive, packages.sury.org, and on that morning its Ubuntu 24.04 index for arm64, which is what an Apple Silicon Mac builds, still listed 8.5.11, while its amd64 index already had 8.5.12. A rebuild without the cache only helps once the archive you build from has the fix. That one is waiting in a ticket of its own.

The lesson I took away is about what a build cache knows. It remembers what you asked for, and it cannot see that the answer to the same request has changed since. If your Dockerfile installs packages without naming versions, the next deploy does not get the latest release, only whatever the cache stored the last time that step actually ran.

Frequently asked questions

Does apt-get install in a Dockerfile always install the latest package?

Only when the step actually runs. If Docker finds a cached layer for that instruction, it reuses the layer and you get whatever was installed when the step last ran. For a RUN step, the cache key is the instruction's text plus everything before it, and an unpinned package name never changes that text.

How do I make flyctl deploy rebuild without the cache?

Pass --no-cache, for example flyctl deploy --remote-only --no-cache. Every step then runs again, including apt-get install. On Akluma the flag lives in the one script both deploy workflows call, so no one has to remember it, and a rollback that redeploys an earlier image with --image builds nothing and is not slowed down.

How much slower is a Fly.io deploy with --no-cache?

On Akluma, one run each, the deploy step went from 2m19s to 5m00s on staging and from 1m22s to 2m53s on production, about four minutes per release in total. The extra time is small because the Dockerfile copies the whole repository early, so Composer, npm and the Vite build already reran on every code deploy.

Why can't I pin a PHP version from the ondrej PPA?

The PPA removes a superseded version one to two days after the next release arrives, based on 19 versions checked through Launchpad's API. A pinned apt-get install line would therefore fail with "version not found" soon after every new patch. Pinning works when your source keeps old versions available.

Does Laravel Sail get the PHP patch at the same time as Fly?

Not necessarily. Sail's PHP 8.5 image installs from packages.sury.org, a different archive from the Launchpad PPA that Fly's Dockerfile uses. On 22 October its arm64 index still listed 8.5.11 while its amd64 index had 8.5.12, so an uncached Sail rebuild on an Apple Silicon Mac installed the old version. Check sail php -v after rebuilding.


Further reading: the php.net security advisory for the October 2026 releases, Docker's documentation on how the build cache works, and the flyctl deploy reference, which documents --no-cache.

Akluma is the free savings tracker this deploy keeps patched: set a savings goal, and it builds a dated plan and emails you a reminder on each saving date. It runs on the web at akluma.com and on Google Play.

No comments yet. Be the first to comment.