Docker for PHP: Building CLI and PHP-FPM Images the Right Way

Build production-ready Docker images for PHP CLI and PHP-FPM applications, install PHP extensions and Composer dependencies inside the image, use multi-stage builds to keep the final image slim, run containers as a non-root user, enable OPcache for production, and minimize image size.

Docker has become the standard way to package and ship PHP applications because it collapses 'works on my machine' into 'works, full stop' — the exact PHP version, extensions, OS packages, and configuration travel together in one artifact that runs identically on a developer's laptop, in CI, and in production. But a naive Dockerfile that just installs PHP and copies in the code misses most of what makes a PHP image actually good: the right extensions, a lean final image, a non-root runtime user, and OPcache tuned for production rather than development. This lesson builds up a real, multi-stage Dockerfile for a PHP-FPM application step by step, covering CLI images along the way.

Base Images: CLI, FPM, and Apache Flavors

The official php Docker Hub image ships in several flavors that map directly onto the ways PHP can run, discussed in an earlier lesson. php:8.3-cli is a bare PHP interpreter meant for running scripts and command-line tools — the natural choice for a worker process, a queue consumer, a cron job container, or a CI step that just needs php and composer available. php:8.3-fpm includes the PHP-FPM process manager and is meant to be paired with a separate web server container (almost always Nginx) that proxies .php requests to it over FastCGI. php:8.3-apache bundles Apache with mod_php baked in, giving you a single container that serves HTTP directly — simpler to wire up but less flexible and, in most production setups today, less common than the Nginx + FPM pairing.

Each of these flavors also comes in -alpine and Debian-based ('slim'-ish, though the plain tag is Debian-full) variants. -alpine images are built on Alpine Linux, which uses musl libc instead of glibc and a package manager (apk) that produces dramatically smaller images — often a third the size of the Debian equivalent. The tradeoff is that Alpine's musl libc has subtly different behavior from glibc in a few edge cases (mainly around DNS resolution and certain C extensions), and some PHP extensions or Composer packages with native dependencies can be fussier to compile against musl. For most typical web applications, Alpine is the right default; for applications with unusual native dependencies, the Debian-based image trades some size for fewer surprises.

For a CLI worker (e.g. a queue consumer built on Symfony Messenger or Laravel Horizon), the base is php:8.3-cli-alpine; for the web-facing application server, it's php:8.3-fpm-alpine. Pinning the exact tag (8.3-fpm-alpine, not just fpm-alpine or latest) is essential — an unpinned tag can silently pull a new PHP minor version on a rebuild and change behavior in production without anyone touching application code.

• php:X.Y-cli — bare interpreter, for scripts/workers/cron/CI, no web server involved. • php:X.Y-fpm — PHP-FPM process manager, paired with a separate Nginx container. • php:X.Y-apache — Apache + mod_php bundled in one container; simpler, less flexible. • -alpine variants are much smaller (musl libc) — the right default unless native deps cause friction.

Installing PHP Extensions in the Image

The official PHP images include a helper script, docker-php-ext-install, specifically for compiling and enabling PHP extensions that ship as source with PHP itself (like pdo_mysql, mbstring, gd, bcmath, opcache, intl, zip). A typical line looks like RUN docker-php-ext-install pdo_mysql mbstring opcache. Some extensions need system libraries present before they'll compile — gd needs libpng/libjpeg headers, zip needs libzip, intl needs libicu — so those get installed first via the distro's package manager (apk add --no-cache on Alpine, apt-get install on Debian) before calling docker-php-ext-install.

For extensions that aren't bundled with core PHP — most commonly Redis, MongoDB, or APCu — the companion tool docker-php-ext-enable isn't enough by itself; instead you use PECL (RUN pecl install redis && docker-php-ext-enable redis), which downloads, compiles, and installs the extension from the PHP Extension Community Library before enabling it in php.ini. A common gotcha: PECL and the extension-compile toolchain need build tools (a C compiler, make, autoconf) that aren't present in the slim runtime image by default — this is exactly the kind of dependency multi-stage builds exist to keep out of the final image, covered in the next section.

A production Dockerfile also copies in a custom php.ini (or a small .ini file dropped into the conf.d scan directory) to set values that differ from PHP's defaults — memory_limit, upload_max_filesize, opcache.* settings, and timezone — rather than relying on whatever the base image ships with, since PHP's out-of-the-box defaults are not tuned for any particular application's needs.

• docker-php-ext-install <ext> — compiles and enables a core-bundled extension (pdo_mysql, gd, opcache, etc.). • System libraries (libpng, libzip, libicu) often must be apt/apk-installed before the extension compiles. • pecl install <ext> && docker-php-ext-enable <ext> — for non-bundled extensions like redis or mongodb. • Copy a custom php.ini/conf.d file into the image rather than relying on unmodified defaults.

Installing Composer Dependencies Inside the Image

Composer itself isn't part of the base PHP image, so it's added by copying the official Composer binary out of its own small Docker image: COPY --from=composer:2 /usr/bin/composer /usr/bin/composer. This 'borrow a binary from another image' pattern avoids needing curl/install scripts and always gets a specific, pinned Composer version. Once Composer is available, dependencies are installed with composer install --no-dev --optimize-autoloader --no-interaction, where --no-dev excludes development-only packages (PHPUnit, debugging tools) from the production image, and --optimize-autoloader generates a classmap-based autoloader that's meaningfully faster than the default PSR-4 lookup at runtime.

Ordering matters a great deal for build speed: COPY composer.json composer.lock ./ followed by RUN composer install ... and only then COPY . . for the rest of the application source means Docker's layer cache can reuse the (often slow) composer install layer on every rebuild where dependencies haven't changed, even though application code changes on every commit. Reversing that order — copying all the source first — busts the dependency-install cache on every single code change, making every build slow.

It's also standard to add a .dockerignore file (covered in the size-minimization section) that excludes the local vendor/ directory from the build context entirely, so a stale, host-installed vendor/ never accidentally gets copied into the image in place of a freshly, correctly resolved one.

• Borrow the Composer binary from the official composer image via multi-stage COPY --from. • composer install --no-dev --optimize-autoloader --no-interaction for production installs. • Copy composer.json/composer.lock and run install BEFORE copying the rest of the source, for layer-cache reuse. • Exclude vendor/ via .dockerignore so a host-installed copy never leaks into the image.

Multi-Stage Builds: Separating Build Tools from the Runtime Image

A multi-stage Dockerfile uses more than one FROM instruction, where each FROM starts a new, independent build stage, and later stages can selectively COPY --from=<earlier-stage> just the specific files they need — discarding everything else that stage produced or installed. This is exactly the tool for the PHP problem described above: a build stage installs Composer, compiles PECL extensions that need a C toolchain, resolves dependencies, and runs any asset build steps, while the final runtime stage starts fresh from a slim base image and copies over only the compiled application: the vendor/ directory, the extension .so files, and the application source — never the compiler, PECL's build cache, or dev dependencies.

This matters because build tools are a security and size liability that has no business in a running production container: a C compiler in a production image is one more thing an attacker who gains code execution could use, and PECL/apt build caches routinely add tens to hundunk of megabytes that serve no runtime purpose. A well-built multi-stage image can be a fraction of the size of a single-stage equivalent purely from dropping build-only tooling in the final layer.

The general shape is: stage 1 (FROM composer:2 AS vendor) resolves Composer dependencies in isolation; stage 2 (FROM php:8.3-fpm-alpine AS build) installs build dependencies and compiles any extensions needing compilation; stage 3 (the final, unnamed or AS runtime stage) starts from the same slim base image fresh, installs only runtime (not build) system packages, and COPY --from=vendor / COPY --from=build pulls in just the finished artifacts. Each intermediate stage is discarded from the final image entirely — only what's explicitly copied forward survives.

• Multiple FROM instructions = multiple stages; later stages COPY --from=<stage> selectively. • Build stage: compiler, PECL, Composer, dev dependencies — never present in the final image. • Runtime stage: starts fresh from a slim base, copies in only compiled/resolved artifacts. • Keeps the shipped image small and removes attack-surface tooling that has no runtime purpose.

Running as a Non-Root User

By default, many Docker images — including PHP's official ones in some configurations — run processes as root inside the container. If an attacker exploits a vulnerability in the application (a deserialization bug, a command-injection flaw, a vulnerable dependency) and gains code execution, running as root means that code execution inherits root's full permissions inside the container: it can modify any file the container can see, install packages, and has meaningfully more room to attempt a container-escape than an unprivileged user would. Running as a dedicated, unprivileged user is a basic defense-in-depth measure that costs little and meaningfully narrows what a successful exploit can do.

In a Dockerfile this means creating a dedicated user and group (RUN addgroup -g 1000 appgroup && adduser -u 1000 -G appgroup -D appuser on Alpine), making sure that user owns the application files it needs to read (and, ideally, nothing it doesn't need to write), and switching to it with a USER appuser instruction before the container's default command runs. For PHP-FPM specifically, the FPM pool configuration also has its own user/group directives (in www.conf or a pool .conf file) that control which OS user the actual worker processes that handle requests run as — this needs to be set consistently with the Dockerfile's USER, since FPM's master process may start as one user while workers, correctly configured, drop to the unprivileged one.

A subtlety worth remembering: USER appuser only affects processes started via that image's CMD/ENTRYPOINT going forward — file permissions set up earlier in the Dockerfile (before the USER switch) with COPY --chown=appuser:appgroup . . need to be set explicitly, because a plain COPY defaults to root ownership regardless of a later USER instruction.

• Running as root inside a container gives a successful exploit unnecessarily broad permissions. • Create a dedicated non-root user/group and USER appuser before the container's default command runs. • Use COPY --chown=appuser:appgroup — a later USER instruction doesn't retroactively fix prior COPY ownership. • PHP-FPM's own pool config (user/group directives) must match, since FPM workers run as whatever it specifies.

Enabling OPcache for Production Images

OPcache caches PHP's compiled bytecode (opcodes) in shared memory between requests, so the Zend Engine doesn't re-lex, re-parse, and re-compile the same unchanged PHP file on every single request — this is one of the single highest-impact PHP performance settings available, and a production image should never ship without it enabled. The opcache extension ships with PHP core, so enabling it is simply RUN docker-php-ext-install opcache (or docker-php-ext-enable opcache if it's already compiled in) plus a small .ini file dropped into the extension-scan directory.

The production-tuned settings matter as much as enabling the extension at all. opcache.validate_timestamps=0 tells OPcache to never check whether a source file changed on disk since it was cached — in development this must be 1 (or timestamps checked frequently) so edits are picked up immediately, but in production, where the container's filesystem is immutable between deploys anyway, checking timestamps on every request is pure wasted work, and disabling it is both faster and correctly matches the deployment model (a new deploy is a new container, not an in-place file edit). opcache.memory_consumption (in MB) should be sized to comfortably hold the application's entire compiled codebase — too small causes constant cache eviction and thrashing, which can be worse than no cache at all.

It's also common to RUN php -r "opcache_compile_file(...)" over every application file at image-build time, or use opcache.preload (PHP 7.4+) to eagerly compile and cache a defined set of files the moment PHP-FPM starts — before the very first request ever arrives — eliminating even the very first request's compilation cost, sometimes called cache 'warming'.

• opcache.enable=1 — the extension must actually be turned on, not just installed. • opcache.validate_timestamps=0 in production — the image is immutable per deploy, so timestamp checks are wasted work. • opcache.memory_consumption sized to hold the entire compiled codebase, avoiding eviction thrashing. • opcache.preload (7.4+) can warm the cache at FPM startup so even the first request is fast.

Minimizing Image Size

Image size affects deploy speed (larger images take longer to push/pull, slowing every deploy and rollback), storage costs across a registry with many historical tags, and attack surface (every installed package is one more thing that could have a vulnerability). The single biggest lever, as covered above, is a multi-stage build that never ships build tools in the final image. Beyond that, choosing an -alpine (or Debian '-slim' where Alpine causes native-extension friction) base image over the full Debian image saves tens to hundreds of megabytes before a single application file is added.

Package manager cache cleanup matters within a single RUN instruction: apk add --no-cache <pkgs> avoids leaving apk's package index cached in the layer at all, while the Debian equivalent needs an explicit rm -rf /var/lib/apt/lists/* in the same RUN line as the apt-get install (chaining with &&, not a separate RUN, since each RUN creates its own layer, and a later layer's deletion doesn't shrink an earlier layer's already-committed size).

A .dockerignore file — analogous to .gitignore — excludes files from ever entering the build context in the first place, which speeds up builds (Docker doesn't need to hash and send excluded files to the daemon) and, more importantly, prevents them from accidentally being copied into an image layer by a broad COPY . .: things like .git/, vendor/ (regenerated fresh inside the image), node_modules/, .env (never belongs in an image at all), and test/CI configuration that has no runtime purpose.

• Multi-stage builds are the single biggest size lever — build tools never reach the final image. • Prefer -alpine (or -slim) base images over full Debian/Ubuntu bases. • Clean package manager caches in the SAME RUN line as the install (apk add --no-cache, or apt-get ... && rm -rf /var/lib/apt/lists/*). • Use .dockerignore to keep .git/, vendor/, node_modules/, and .env out of the build context entirely.

Extension Install Pattern

Chaining with && in one RUN line keeps intermediate apk state out of a separate, permanent layer. pecl-installed extensions need a separate docker-php-ext-enable call to register them in php.ini.

# System libraries an extension needs to compile against
RUN apk add --no-cache libzip-dev libpng-dev icu-dev \
    && docker-php-ext-install pdo_mysql mbstring gd intl zip opcache \
    && pecl install redis \
    && docker-php-ext-enable redis

Code Examples

Three stages, three jobs: 'vendor' resolves Composer dependencies in total isolation from the app image, 'build' compiles PHP extensions using a C toolchain that is entirely discarded afterward, and 'runtime' starts fresh from the same slim base and copies over only the finished, compiled artifacts — never the compiler, PECL cache, or dev dependencies. The final image runs as an unprivileged appuser and never contained build tooling in any of its layers. $PHPIZE_DEPS is a built-in variable in the official PHP images listing the packages phpize/PECL need to compile extensions from source.

validate_timestamps=0 is the key production setting: because a new deploy means a brand-new immutable container rather than an in-place file edit, OPcache never needs to check whether a file changed — it can trust its cache completely, which removes a filesystem stat() call on every single cached file, on every single request.

Common mistake: Leaving opcache.validate_timestamps=1 (the safe development default) in a production image works correctly but silently gives up a meaningful amount of performance for a safety check the deployment model doesn't need.

A CLI-flavored worker image needs no web server or FPM at all — just the bare interpreter plus whatever extensions the worker logic needs (here, Redis for a queue backend) — and follows the same non-root and multi-stage-for-vendor pattern as the FPM image.

# ---- Stage 1: resolve Composer dependencies ----
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
    --no-dev \
    --no-interaction \
    --no-scripts \
    --optimize-autoloader \
    --ignore-platform-reqs

# ---- Stage 2: build/compile stage with dev tooling ----
FROM php:8.3-fpm-alpine AS build
RUN apk add --no-cache --virtual .build-deps \
        $PHPIZE_DEPS libzip-dev libpng-dev icu-dev \
    && docker-php-ext-install pdo_mysql mbstring gd intl zip opcache \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && apk del .build-deps \
    && apk add --no-cache libzip libpng icu-libs

# ---- Stage 3: slim final runtime image ----
FROM php:8.3-fpm-alpine AS runtime

# Copy only the compiled extensions from the build stage
COPY --from=build /usr/local/lib/php/extensions /usr/local/lib/php/extensions
COPY --from=build /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d

# Production PHP/OPcache configuration
COPY docker/php.ini /usr/local/etc/php/conf.d/zz-app.ini

# Non-root user
RUN addgroup -g 1000 appgroup && adduser -u 1000 -G appgroup -D appuser

WORKDIR /var/www/html
COPY --from=vendor --chown=appuser:a
…
; docker/php.ini — copied into /usr/local/etc/php/conf.d/zz-app.ini
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.jit_buffer_size=64M
opcache.jit=tracing

memory_limit=256M
upload_max_filesize=20M
post_max_size=20M
date.timezone=UTC
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction

FROM php:8.3-cli-alpine
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && apk del .build-deps

RUN addgroup -g 1000 appgroup && adduser -u 1000 -G appgroup -D appuser
WORKDIR /app
COPY --from=vendor --chown=appuser:appgroup /app/vendor ./vendor
COPY --chown=appuser:appgroup . .
USER appuser

CMD ["php", "artisan", "queue:work", "--sleep=3", "--tries=3"]

Multi-Stage Build Data Flow

Only the arrows crossing into the final stage matter — everything else (compilers, caches, dev deps) never leaves its stage.

Stage: vendor         Stage: build              Stage: runtime (final)
 ┌────────────────┐    ┌────────────────────┐    ┌───────────────────────┐
 │ composer install│    │ apk add build tools │    │ FROM php:8.3-fpm-alpine│
 │ -> vendor/       │    │ compile extensions   │    │ COPY --from=build ext  │
 └────────────────┘    │ -> .so files          │    │ COPY --from=vendor vend│
        │               └────────────────────┘    │ COPY app source        │
        │  COPY --from=vendor                │  COPY --from=build          │
        └─────────────────────────────────────┴──────────────▶ USER appuser
                                                                CMD php-fpm

Important Rules

Pin exact base image tags (e.g. php:8.3-fpm-alpine) — never build against latest.

Copy composer.json/composer.lock and install dependencies before copying the rest of the source, for cache reuse.

Never ship a C compiler, PECL build cache, or dev dependencies in the final runtime image.

Always run the container process as a dedicated non-root user via USER.

Set opcache.validate_timestamps=0 in production images — the deployment model makes it unnecessary and it costs performance.

Image Size in a High-Deploy-Frequency Shop

A team deploying dozens of times a day found that switching from a single-stage, Debian-based PHP image (over 900MB) to a multi-stage Alpine build (under 150MB) cut their average deploy time roughly in half, purely from the smaller image needing far less time to push to and pull from the container registry on every single deploy.

FROM php:8.3-fpm-alpine RUN apk add build-base RUN pecl install redis COPY . . CMD ["php-fpm"]

A single-stage Dockerfile ships the entire build toolchain (compiler, headers, PECL cache) in the production image permanently, bloating its size and needlessly widening its attack surface.

FROM php:8.3-fpm-alpine COPY.. RUN composer install FROM php:8.3-fpm-alpine COPY composer.json composer.lock./ RUN composer install COPY..

Copying all application source before installing dependencies busts Docker's layer cache on every single code change, forcing a full, slow composer install on every rebuild instead of only when composer.lock actually changes.

RUN addgroup -g 1000 appgroup && adduser -u 1000 -G appgroup -D appuser USER appuser

Running the application process as root means a successful code-execution exploit inherits root's full permissions inside the container, meaningfully widening what an attacker can do after a breach.

What is the main purpose of a multi-stage Docker build for a PHP application? A. To run tests automatically B. To keep build-only tools (compilers, PECL cache, dev dependencies) out of the final runtime image C. It is required for PHP-FPM to work at all D. To support multiple PHP versions in one container

B. To keep build-only tools (compilers, PECL cache, dev dependencies) out of the final runtime image — Multi-stage builds let a build stage do compilation and dependency resolution with tools that are then discarded, so the final stage ships only the compiled artifacts, keeping the runtime image small and reducing attack surface.

Why should composer.json and composer.lock be copied and composer install run BEFORE copying the rest of the application source? A. Composer requires it syntactically B. It has no effect on build behavior C. It lets Docker's layer cache reuse the dependency-install layer when only application code (not dependencies) has changed D. It reduces the number of PHP extensions needed

C. It lets Docker's layer cache reuse the dependency-install layer when only application code (not dependencies) has changed — Docker caches layers in order; copying dependency manifests and installing first means that layer is only invalidated when composer.lock changes, not on every application code change.

What does opcache.validate_timestamps=0 mean, and why is it appropriate for a production container image? A. It disables OPcache entirely B. It makes OPcache skip checking whether a cached file changed on disk, which is safe because a new deploy means a whole new immutable container rather than an in-place edit C. It forces OPcache to recompile every file on every request D. It only affects PHP CLI, not PHP-FPM

B. It makes OPcache skip checking whether a cached file changed on disk, which is safe because a new deploy means a whole new immutable container rather than an in-place edit — Since a production deploy ships a brand-new container image rather than editing files in place, OPcache never needs to detect in-place changes, so skipping the timestamp check is both safe and faster.

Why run the PHP process as a non-root user inside the container? A. It makes the image build faster B. Root users cannot run PHP-FPM at all C. It limits what a successful application-level exploit can do inside the container, as a defense-in-depth measure D. It is required for Composer to function

C. It limits what a successful application-level exploit can do inside the container, as a defense-in-depth measure — If application code is compromised, running as an unprivileged user restricts what the attacker's code execution can do inside the container compared to running as root.

Write a two-stage Dockerfile for a PHP-CLI application that: stage 1 uses the official composer image to install production dependencies from composer.json/composer.lock; stage 2 uses php:8.3-cli-alpine, creates a non-root user named 'worker', copies in the vendor directory and application source with correct ownership, switches to the worker user, and runs php app.php as the default command.

FROM composer:2 AS vendor WORKDIR /app COPY composer.json composer.lock ./ RUN composer install --no-dev --optimize-autoloader --no-interaction FROM php:8.3-cli-alpine RUN addgroup -g 1000 workergroup && adduser -u 1000 -G workergroup -D worker WORKDIR /app COPY --from=vendor --chown=worker:workergroup /app/vendor ./vendor COPY --chown=worker:workergroup . . USER worker CMD ["php", "app.php"]

Remember --chown on the COPY instructions and to place USER after all root-owned setup steps are done.