Why updating the application does not change PHP
A WordPress update, a composer update or a new Nextcloud release replaces application files. The PHP interpreter is a program installed in the Docker image, just like the Debian or Alpine system around it. As long as the image does not change, PHP does not change, however many times the application is updated.
The second trap is the restart. docker restart restarts the existing container, so the same image. docker compose up -d does not look for a newer version when an image with that name already exists on the server: it reuses the local copy. Only a pull or a build --pull, followed by recreating the container, changes the interpreter.
The two flaws and the apt or dnf commands are covered in our PHP update guide. Here the subject is the Docker image and the proof that it is the right one.
Six objects not to confuse
- The registry image — what Docker Hub or your private registry serves today under a tag such as
php:8.3-fpm. A tag is mobile: it points to different content after every rebuild. - The local image — the copy on your server, visible with
docker image ls. It only moves when you pull or build an image. - The running container — it keeps the ID of the image it was created from, even if a newer image now carries the same name.
- The process that answers — PHP-FPM, Apache with
mod_phpor FrankenPHP. It is the exposed one, andphp -vdoes not query it. - The other services — queue worker, scheduler, maintenance
clicontainer: each has its own image, hence its own PHP version. - The build cache — a cached
FROMlayer can replay the old base without any error being shown.
Compare the registry, the local image and the container
List local images and their date
docker image ls php(orwordpress,nextcloud). TheCREATEDcolumn shows when the image was built. Aphp:8.3-fpmimage created before October 21, 2026 does not contain the fix.Read each container's image
docker psshows in theIMAGEcolumn the tag requested at creation. To get the real ID of the image in use, query the container:docker inspect --format '{{.Image}}' <container>.Compare with the image on disk
docker image inspect --format '{{.Id}}' php:8.3-fpmreturns the ID of the image that carries that name today. If it differs from the container's, the container runs on an image older than the one on disk: it was never recreated after the last pull.Compare with the registry
docker pull php:8.3-fpmends withStatus: Downloaded newer image for …when a newer image arrived, orStatus: Image is up to date for …otherwise. In the second case, either your copy is already the latest or the tag has not been republished yet: check it against the tag date on Docker Hub.
Read the version the web really serves
php -v queries the command-line program, not the process that answers requests. The PHP_SAPI constant tells which one is speaking: cli on the command line, fpm-fcgi for PHP-FPM, apache2handler for Apache with mod_php. In a single image the two versions almost always match. The gap appears in three common Docker situations: a docker compose run starts a fresh container, so from the latest local image, while the web service still runs the old one; a maintenance cli container is built on a different tag than the web service; a reverse proxy forwards to a different container than the one you inspect.
The most reliable proof remains the HTTP request: it follows a visitor's path and reads PHP_VERSION in the process that answers. Check the FPM binary as a complement with docker exec <container> php-fpm -v.
Prove the served version, service by service
Drop a test file in the served folder
Example with the
wordpressservice of the official image, whose web folder is/var/www/html(adapt the service and path to your stack):docker compose exec wordpress sh -c 'echo "<?php echo PHP_VERSION, \" \", PHP_SAPI;" > /var/www/html/v-check.php'Query it over HTTP, then delete it right away
curl -s https://your-domain.com/v-check.phpthendocker compose exec wordpress rm /var/www/html/v-check.php. The answer gives the version followed by the SAPI. Compare it with the fixed versions: 8.5.12, 8.4.27, 8.3.36 or 8.2.35 depending on the branch. A lower number for your branch means the fix is not there.Check the container's FPM binary
docker exec <container> php-fpm -vprints the version and SAPI of that specific container's FPM binary. With an Apache image, the HTTP request from the previous step is enough:mod_phpis loaded by Apache.Do not trust docker compose run
docker compose runcreates a fresh container from the local image. It can report the fixed version while the site's container has not moved. Always check the container that receives traffic.
Floating tags and pinned tags: what moves by itself
A branch tag such as php:8.3-fpm advances at the next pull: it then points to the fixed version. An exact tag such as php:8.3.36-fpm stays on its version, and a digest reference (@sha256:…) never moves. The docker image pull documentation is explicit: with a digest, Docker fetches no rebuild, security fixes included, until you change the digest yourself.
The fixed tags of the official php image reached Docker Hub during the day and evening of October 21, 2026 (UTC), for the FPM variants of branches 8.5, 8.4, 8.3 and 8.2. The official WordPress images php8.3-fpm, php8.4-fpm and php8.3-apache were republished on October 22, 2026 in the early morning (UTC), and Nextcloud's the same day. An image pulled before those moments is therefore the old one, even with an identical name. Check these dates on each tag's page. Branch 8.2 gets its fix but reaches end of life on December 31, 2026: plan its replacement.
Which notation moves, which stays frozen
Scroll the table
| In your compose file or Dockerfile | Moves at the next pull? | To get the fixed PHP |
|---|---|---|
| `php:8.3-fpm` | Yes, the tag is republished | `pull` or `build --pull`, then recreate the container |
| `php:8.3.36-fpm` | No, exact version | Already fixed; change it by hand at the next fix |
| `php:8.3.<n>-fpm` with n below 36 | No, exact version | Move to a fixed tag, rebuild, recreate |
| `wordpress:php8.3-fpm` | Yes, the maintainers republish it | `pull`, then recreate the service |
| `wordpress:<x.y>-php8.3-fpm` | Yes for PHP: the maintainers republish it | `pull`, then recreate the service |
| `php:8.3-fpm@sha256:…` | Never | Replace the digest with that of a fixed image |
| `FROM php:8.3-fpm` in your Dockerfile | Only if you build with `--pull` | `build --pull`, then recreate the container |
| `image: myregistry/app:latest` (your image) | No, it is your last build | Rebuild, republish, then pull and recreate |
Custom image: the sequence and what each option changes
For an image built in-house from FROM php:8.3-fpm, the sequence takes three commands: docker compose build --pull --no-cache, then docker compose up -d --force-recreate, then a check of the served version. Each answers a specific trap.
--pull is the option that changes the PHP version: it tells Docker to look for a newer version of the base image instead of reusing the server's. Without it, the FROM is resolved with the local copy, and the build succeeds with the old interpreter, without a single message.
--no-cache replays every layer, including apt-get and composer install, which also renews system libraries and dependencies. It is not needed to change PHP, because when the base image changes the following layers are rebuilt on their own. It is useful when a cached layer freezes a package you want renewed.
--force-recreate recreates containers even if Compose considers their configuration and image unchanged. That removes the doubt about what is running.
Rebuilding your images at each fix is a schedule to keep, not a one-off action. On a VPS with root access, that schedule is yours, and our guide to installing Docker on a VPS lays the groundwork.
Rebuild and recreate a custom image
Check that the base tag is fixed
Open your base tag's page on Docker Hub and check that it was published from October 21, 2026. If your
FROMtargets an older exact version, change it first.Rebuild while pulling the base
docker compose build --pull --no-cache. Note the version before the operation with the test file, to have a before and an after.Recreate the containers
docker compose up -d --force-recreate. A single command can also chain the three steps:docker compose up -d --build --pull always. Recreate every service that uses the image: web, queue worker, scheduler.Prove the result
Run the test file again, then compare the ID given by
docker inspect --format '{{.Image}}' <container>with that ofdocker image ls. The old and new images can carry the same name: only the ID and the HTTP answer are proof.
Official WordPress and Nextcloud: PHP comes from the tag
A WordPress tag combines a WordPress version, a PHP version and a variant, for example php8.3-apache or php8.3-fpm. The PHP branch is chosen by the tag name. Changing the WordPress version, or letting automatic updates act in the data volume, does not touch that branch. To get the fixed PHP, re-pull the tag with docker compose pull, then recreate the service. The Apache variant is the default one, and it loads PHP as mod_php: the PHP project considers it the most exposed, with an exploit demonstrated before any application code runs. Treat it as a priority.
Nextcloud follows the same principle, in two variants: apache ships a full Apache, fpm needs a web server in front of it. The image documentation describes the update as a replacement: pull the image, throw away the old container, start the new one. Nextcloud's code and your data live in the /var/www/html volume, which recreating the container does not erase. See also our WordPress on VPS guide and the Nextcloud one.
Laravel Octane and FrankenPHP: the engine is in the image
With Octane and FrankenPHP there is no PHP-FPM: the application server embeds the interpreter. The official FrankenPHP images are built on the official PHP images, and their tag combines the FrankenPHP version, the PHP version and the system, in the form dunglas/frankenphp:<version>-php8.4-trixie. The project rebuilds them at each release and, every day at 4 am UTC, if new official PHP images exist. Your FROM dunglas/frankenphp only gets the fix if you rebuild your image with --pull.
The trap specific to Octane is the worker. Octane boots the application once and keeps it in memory: php artisan octane:reload gracefully restarts the workers to load the deployed code. It is the right move after a code deployment, but it does not replace the interpreter, which remains that of the running image. The same goes for a kill -USR2 or a PHP-FPM reload run in the old container: they restart the same files, those of the old image. To change PHP, recreate the container, including those of queue workers and the scheduler. See our Laravel Octane on VPS guide.
Infrastructure traps that replay the old image
- CI build cache — a restored layer cache can replay the
FROMlayer. Add--pullto the CI build command, or invalidate the cache for that step. - Private registry or mirror — if your servers pull from an internal registry that copies images by hand or on a schedule, check that it copied the tag. Otherwise
pullanswersImage is up to datewhile the maintainers have republished. - Portainer stack or orchestration tool — updating a stack does not always re-pull the image. Tick the re-pull option before redeploying if your tool offers one.
pull_policy: missing— it is Compose's default without abuildsection: it only pulls if the image is absent from the server. Only thelatesttag is pulled in every case. Usealways, ordailyandweeklyto check the registry at a fixed interval.- A forgotten service — web, worker, scheduler and
clicontainer built from separate Dockerfiles each have their own base: fix them all. - An orphan container — a forgotten
docker runor another Compose project may still answer on a port. List everything running withdocker ps.
Troubleshooting: what you see, what it means
Scroll the table
| What you notice | Probable cause | Remedy |
|---|---|---|
| `pull` prints `Status: Image is up to date` but the version stays old | The tag has not been republished yet, or you pull an exact version or a digest | Compare with the tag date on Docker Hub, then move to a fixed tag |
| `up -d` changes nothing after a pull | The existing container was not recreated | `docker compose up -d --force-recreate`, then compare image IDs |
| The build finishes in a few seconds, with the base already cached | `--pull` is missing: the server's base is reused | `docker compose build --pull`, with `--no-cache` if system packages must also be renewed |
| `docker compose run` reports the fixed version, the site answers the old one | `run` creates a fresh container, the web service was not recreated | Check the container that receives traffic over HTTP, then recreate it |
| The version is right but a worker behaves as before | A queue or Octane worker still runs in an old container | Recreate every container that uses the image, not only the web service |