A secure PHP Docker image comes from four decisions: a supported and pinned base tag, a slim variant, a non-root runtime user, and a scanned dependency tree. Most PHP Docker problems trace back to skipping one of these, usually running an unsupported PHP version or shipping a fat image full of build tools. This guide walks through building a PHP Docker image you can defend, with real tags and a Dockerfile you can adapt.
Pick a base tag that is still supported
The official php image on Docker Hub publishes tags for the actively supported PHP branches, currently PHP 8.3 and 8.4, with 8.5 tags appearing as it stabilizes. Each PHP branch gets roughly two years of active support followed by a year of security-only fixes, after which it is end of life. Running a PHP Docker image built on an EOL branch means you stop getting security patches for the interpreter itself, and no amount of application hardening fixes that.
Choose the variant that matches your workload:
php:8.3-fpmorphp:8.4-fpmfor running behind Nginx or a reverse proxy.php:8.3-clifor workers and scheduled jobs.php:8.3-apacheif you want Apache bundled in the same container.
Pin to a specific patch tag rather than a floating major, so builds are reproducible:
FROM php:8.3.31-fpm-alpine3.24
Alpine or Debian: the size and CVE tradeoff
The -alpine variants produce a much smaller PHP Docker image, which means fewer packages and therefore a smaller attack surface and fewer CVEs to triage. The tradeoff is that Alpine uses musl instead of glibc, and some PHP extensions or third-party binaries expect glibc. If your extensions build cleanly on Alpine, prefer it. If you hit musl compatibility issues, the Debian-based default (php:8.3-fpm, roughly bookworm) is the safe fallback; just accept the larger surface and scan accordingly.
Install extensions the right way
PHP extensions are the part of a Dockerfile people copy blindly. Use the helper scripts the official image ships (docker-php-ext-install, docker-php-ext-configure) and remove build dependencies in the same layer so they do not bloat the final image:
FROM php:8.3.31-fpm-alpine3.24
RUN apk add --no-cache --virtual .build-deps \
$PHPIZE_DEPS libpng-dev libzip-dev \
&& docker-php-ext-install -j"$(nproc)" gd zip pdo_mysql opcache \
&& apk del .build-deps
WORKDIR /var/www/html
The --virtual .build-deps group plus apk del in one RUN means the compilers never land in a shipped layer. That keeps the image slim and removes tools an attacker would otherwise find inside your container.
Run as a non-root user
By default many PHP Docker images run as root. If an attacker gets code execution inside a root container, the blast radius is far larger. The -fpm and -apache images already include a www-data user; switch to it before the container starts serving:
RUN chown -R www-data:www-data /var/www/html
USER www-data
For CLI workers, create or reuse a non-root user the same way. This single line closes off a large class of container escape and privilege-escalation scenarios.
Keep Composer dependencies out and locked
Do not run Composer inside your production runtime image. Use a multi-stage build so Composer and its cache stay in a builder stage, and only the resolved vendor/ directory is copied into the final image:
FROM composer:2 AS build
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --optimize-autoloader
FROM php:8.3.31-fpm-alpine3.24
WORKDIR /var/www/html
COPY --from=build /app/vendor ./vendor
COPY . .
RUN chown -R www-data:www-data /var/www/html
USER www-data
The --no-dev flag is a security control, not just an optimization: it keeps test frameworks and debug tools out of production. Commit composer.lock so the exact resolved versions ship, and scan them. Your PHP dependencies carry CVEs just like the base image does, and an SCA tool can flag a vulnerable Composer package before the image is pushed.
Scan the image before you push it
A PHP Docker image bundles two dependency trees: the OS packages from the base layer and your Composer packages. Scan both. Wire a container scan into CI so the pipeline fails on a critical CVE instead of shipping it:
# example: scan the built image for OS and library CVEs
docker build -t myapp:latest .
trivy image myapp:latest
Rebuild on a schedule too. A PHP Docker image you built three months ago is running three months of unpatched base-layer packages even if your code never changed. Rebuilding weekly against the latest patch tag pulls in upstream fixes automatically.
Putting it together
A defensible PHP Docker image is small, pinned to a supported patch tag, built in stages so no compilers or dev dependencies ship, run as www-data, and scanned on every build. None of these steps is expensive, and together they eliminate the most common findings that show up in container audits.
FAQ
Which PHP Docker base image should I use?
Use the official php image with a supported branch (currently 8.3 or 8.4), pinned to a specific patch tag. Pick -fpm behind a proxy, -cli for workers, or -apache for a bundled web server, and prefer the -alpine variant for a smaller PHP Docker image.
Is Alpine safe for a PHP Docker image?
Yes, and it usually has fewer CVEs because it ships fewer packages. The only caveat is musl versus glibc compatibility for some extensions; if an extension fails to build, fall back to the Debian-based default.
How do I keep my PHP Docker image from running as root?
Add USER www-data after fixing ownership of your web root. The official -fpm and -apache images already include the www-data user, so switching to it is a one-line change.
Do I still need to scan a PHP Docker image if my code has not changed?
Yes. The base layer accumulates unpatched OS package CVEs over time. Rebuild against the latest patch tag on a schedule and scan every build for both OS and Composer dependencies.