“Docker is not a virtualization technology. It is an application delivery technology.” - Mike Coleman (Docker)
Table of Contents
- Introduction
- Overview: Multi-Stage Build Strategy (Builder + Production)
- Stage 1: Builder - Compile Extensions, Install Tools & Dependencies
- Stage 2: Production - Minimal Runtime Image
- The .dockerignore File
- Practical Setup (Step-by-step)
- Quotes
- FAQs
- Key Takeaways
- Conclusion
1. Introduction
This document explains how to build a production-ready PHP 8.4-FPM Docker image using a multi-stage Dockerfile. The image ships with all the PHP extensions, CLI tools (Composer, WP-CLI, Drush, Drupal Console), and Node.js needed for WordPress and Drupal projects - while keeping the final image lean and secure.
The approach uses a two-stage build:
- Stage 1 (Builder): Compiles PHP extensions, installs development tools, and downloads CLI utilities.
- Stage 2 (Production): Starts from a clean php:8.4-fpm base, copies only the compiled artifacts and runtime libraries, applies security hardening, and runs as a non-root user.
Why multi-stage?
A single-stage Dockerfile that compiles extensions and installs build tools leaves behind compilers, header files, and package caches that bloat the image and increase the attack surface. Multi-stage builds solve this by discarding everything that is not needed at runtime.
2. Overview: Multi-Stage Build Strategy (Builder + Production)
Stage 1 (Builder) starts from php:8.4-fpm and installs: - 20+ PHP extensions using install-php-extensions (handles build dependencies and cleanup internally). - Composer, WP-CLI, Drush, and Drupal Console. - Node.js 20.x for front-end tooling.
Stage 2 (Production) starts from a fresh php:8.4-fpm and: - Copies compiled extension .so files and their .ini configs from the builder. - Copies CLI binaries (Composer, WP-CLI, Drush, Drupal Console, Node.js). - Installs only the runtime shared libraries needed by the extensions. - Applies OPcache tuning and PHP security settings. - Creates a non-root user (appuser) for PHP-FPM. - Add a health check.
Why separate stages?
- Smaller image: Build tools (gcc, make, autoconf, header files) are discarded. Only runtime libraries remain.
- Faster pulls and deploys: Less data to transfer across registries and hosts.
- Reduced attack surface: No compilers or dev packages in production.
- Clearer troubleshooting: Build failures are isolated to Stage 1; runtime issues are isolated to Stage 2.
3. Stage 1: Builder - Compile Extensions, Install Tools & Dependencies
3.1 Base Image
FROM php:8.4-fpm AS builder
We use the official php:8.4-fpm image (based on Debian 13 Trixie) as the builder base. This gives us the PHP source, phpize, and docker-php-ext-install out of the box.
3.2 PHP Extension Installation
Single tool for all PHP extensions (handles build deps + cleanup internally)
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/bin/
Install all PHP extensions in one layer
RUN install-php-extensions \
bcmath \
bz2 \
calendar \
exif \
gd \
gmp \
imagick \
intl \
mailparse \
mongodb \
mysqli \
opcache \
pcntl \
pdo \
pdo_mysql \
pdo_pgsql \
soap \
sockets \
sodium \
xsl \
zip
Why install-php-extensions?
- It automatically installs the OS-level build dependencies (e.g., libpng-dev, libicu-dev), compiles the extension, and removes the build dependencies - all in one step.
- It replaces the manual apt-get install && docker-php-ext-configure && docker-php-ext-install && apt-get purge pattern.
- It supports PECL extensions (like imagick, mongodb, mailparse) with the same syntax.
- We use COPY --from=mlocati/php-extension-installer to pull the binary directly from the tool’s official image, without adding another FROM stage.
Extensions included and why:
3.3 CLI Tools Installation
Install Composer, WP-CLI, Drush, and Drupal Console in one layer
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
unzip \
&& rm -rf /var/lib/apt/lists/* \
Composer
&& curl -sSL https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
WP-CLI
&& curl -sSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o /usr/local/bin/wp \
&& chmod +x /usr/local/bin/wp \
Drupal Console
&& curl -sSL https://drupalconsole.com/installer -o /usr/local/bin/drupal \
&& chmod +x /usr/local/bin/drupal
- Composer: PHP dependency manager. Installed globally at /usr/local/bin/composer.
- WP-CLI: WordPress command-line interface for managing WordPress installations.
- Drupal Console: CLI tool for generating boilerplate code and interacting with Drupal.
- git and unzip are needed by Composer to fetch packages; they are installed here but not carried over to the production stage.
3.4 Drush Installation
ENV COMPOSER_HOME=/usr/local/share/composer
RUN composer global require drush/drush:8.* --no-interaction --prefer-dist
Drush (Drupal Shell) is installed globally via Composer. COMPOSER_HOME is set explicitly so the entire vendor directory can be copied to the production stage.
3.5 Node.js Installation
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
Node.js 20.x (LTS) is installed for front-end build tools (npm scripts, asset compilation). NVM is intentionally avoided - a single global Node.js installation is simpler and more predictable in a container.
“Build once, run anywhere - but make sure what you build is only what you need.” - Container best practice principle
4. Stage 2: Production - Minimal Runtime Image
4.1 Fresh Base and Labels
FROM php:8.4-fpm AS production
LABEL maintainer="AddWeb Solutions" \
description="PHP 8.4-FPM production image with WordPress/Drupal tooling"
A fresh php:8.4-fpm base - none of the builder’s build tools, caches, or temp files exist here.
4.2 Copying Artifacts from Builder
Copy PHP extensions and their configs from builder
COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/
Copy CLI tools from builder
COPY --from=builder /usr/local/bin/composer /usr/local/bin/composer
COPY --from=builder /usr/local/bin/wp /usr/local/bin/wp
COPY --from=builder /usr/local/bin/drupal /usr/local/bin/drupal
COPY --from=builder /usr/local/share/composer /usr/local/share/composer
Copy Node.js from builder
COPY --from=builder /usr/bin/node /usr/bin/node
COPY --from=builder /usr/bin/npm /usr/bin/npm
COPY --from=builder /usr/bin/npx /usr/bin/npx
COPY --from=builder /usr/lib/node_modules /usr/lib/node_modules
COPY --from=builder selectively pulls only the compiled .so files, .ini configs, and CLI binaries. Everything else (compilers, header files, build caches) is left behind.
4.3 Runtime Libraries
RUN apt-get update && apt-get install -y --no-install-recommends \
# Runtime libs required by PHP extensions
libbz2-1.0 \
libfreetype6 \
libgmp10 \
libicu76 \
libjpeg62-turbo \
libmagickwand-7.q16-10 \
libavif16 \
libpng16-16 \
libpq5 \
libxslt1.1 \
libzip5 \
# Essential runtime utilities only
cron \
curl \
default-mysql-client \
ffmpeg \
sendmail \
supervisor \
unzip \
sqlite3 \
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
Key points:
- Runtime libs only: We install the shared libraries (.so files) that the compiled PHP extensions link against. These are the -dev-less counterparts (e.g., libpng16-16 not libpng-dev).
- Debian Trixie package names: Since php:8.4-fpm is based on Debian 13 (Trixie), some package names differ from Bookworm (e.g., libicu76 instead of libicu72, libmagickwand-7.q16-10 instead of libmagickwand-6.q16-6, libzip5 instead of libzip4).
- Essential utilities: cron (scheduled tasks), curl (health checks, API calls), default-mysql-client (database operations), ffmpeg (media processing), sendmail (email delivery), supervisor (process management), unzip, sqlite3.
- Cleanup: apt-get purge --auto-remove removes packages that were pulled in only as install dependencies, and rm -rf /var/lib/apt/lists/* clears the package cache.
Runtime libraries mapped to extensions:
4.4 Drush Symlink
ENV COMPOSER_HOME=/usr/local/share/composer
RUN ln -s /usr/local/share/composer/vendor/bin/drush /usr/local/bin/drush
Makes drush available in $PATH without modifying the PATH variable.
4.5 OPcache Tuning
RUN { \
echo 'opcache.memory_consumption=128'; \
echo 'opcache.interned_strings_buffer=8'; \
echo 'opcache.max_accelerated_files=4000'; \
echo 'opcache.revalidate_freq=60'; \
echo 'opcache.fast_shutdown=1'; \
echo 'opcache.enable_cli=1'; \
echo 'opcache.validate_timestamps=0'; \
} > /usr/local/etc/php/conf.d/opcache-recommended.ini
validate_timestamps=0 is a production optimization - PHP never starts the filesystem to check if files have changed. When you deploy new code, restart PHP-FPM to pick up the changes.
4.6 Security Hardening
RUN { \
echo 'expose_php=Off'; \
echo 'display_errors=Off'; \
echo 'log_errors=On'; \
echo 'error_log=/dev/stderr'; \
echo 'allow_url_fopen=Off'; \
} > /usr/local/etc/php/conf.d/security.ini
4.7 Non-Root User
RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m appuser
WORKDIR /var/www/html
RUN chown -R appuser:appuser /var/www/html
Running PHP-FPM as a non-root user reduces the blast radius if the application is compromised. The appuser (UID 1000) owns the web root.
4.8 Health Check
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD php-fpm-healthcheck || kill -0 $(cat /var/run/php-fpm.pid 2>/dev/null) || exit 1
Docker checks every 30 seconds whether PHP-FPM is responding. After 3 consecutive failures, the container is marked unhealthy, and orchestrators (Docker Compose, Swarm, Kubernetes) can restart it.
4.9 Expose and CMD
EXPOSE 9000
CMD ["php-fpm"]
PHP-FPM listens on port 9000 (FastCGI). A reverse proxy (Nginx, Apache, Caddy) forwards requests to this port.
5. The .dockerignore File
.git
.github
.gitignore
.md
LICENSE
docker-compose.yml
.env*
.vscode
.idea
node_modules
vendor
.docker
The .dockerignore prevents unnecessary files from being sent to the Docker build context. This speeds up builds and avoids leaking secrets (.env files) or bloating the image with node_modules or vendor directories.
6. Practical Setup (Step-by-step)
6.1 Build the Image
docker build -t php8.4-multistage --target production .
The --target production flag tells Docker to stop at the production stage and discard the builder. If --target is omitted, Docker builds up to the last stage (which is already in production in this Dockerfile).
6.2 Run the Container
docker run -d \
--name php-app \
-p 9000:9000 \
-v ./src:/var/www/html \
php8.4-multistage
Mount your application source code at /var/www/html. PHP-FPM will serve it on port 9000.
6.3 Pair with Nginx (docker-compose example)
version: '3.8'
services:
php:
build:
context: .
target: production
volumes:
- ./src:/var/www/html
networks:
- app-network
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./src:/var/www/html
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- php
networks:
- app-network
networks:
app-network:
driver: bridge
6.4 Verify Extensions
docker exec php-app php -m
This lists all loaded PHP modules. Confirm that gd, imagick, intl, opcache, pdo_mysql, etc., are all present.
6.5 Verify CLI Tools
docker exec php-app composer --version
docker exec php-app wp --version
docker exec php-app drush --version
docker exec php-app node --version
docker exec php-app npm --version
6.6 Check phpinfo
Create a phpinfo file
docker exec php-app bash -c "echo '<?php phpinfo();' > /var/www/html/info.php"
Quick test with built-in server
docker exec -d php-app php -S 0.0.0.0:8085 -t /var/www/html
Open http://localhost:8085/info.php in your browser to see the full PHP configuration. Remove info.php after testing - never leave it exposed in production.
6.7 Common Improvements (optional)
- Use BuildKit cache mounts to speed up repeated builds: RUN --mount=type=cache,target=/var/cache/apt apt-get update && ...
- Pin the PHP version (e.g., php:8.4.18-fpm) for reproducible builds instead of using the floating 8.4-fpm tag.
- Add a .env-based PHP config layer for settings that vary between environments (memory_limit, upload_max_filesize).
- Use Docker Compose profiles to add Xdebug only in development (avoid Xdebug in production).
- Scan the image with Trivy or Docker Scout for vulnerability reporting before pushing to a registry.
“The art of simplicity is a puzzle of complexity.” - Douglas Horton
8. FAQs
Q1. Why use multi-stage builds instead of a single Dockerfile?
A. A single-stage build carries compilers, dev headers, and package caches into the final image. Multi-stage builds discard all of that, resulting in a smaller, more secure image.
Q2. Why install-php-extensions instead of docker-php-ext-install? A. install-php-extensions handles OS dependency installation, extension compilation, and cleanup automatically. It also supports PECL extensions (imagick, mongodb, mailparse) with the same syntax, eliminating the need for separate pecl install commands.
Q3. Why is the base image Debian Trixie? Can I use Bookworm or Alpine?
A. The official php:8.4-fpm tag is built on Debian 13 (Trixie) as of 2025. If you need Bookworm, use php:8.4-fpm-bookworm. Alpine (php:8.4-fpm-alpine) is smaller but uses musl libc, which can cause compatibility issues with some extensions (notably ImageMagick).
Q4. Why are the runtime library package names different from older guides?
A. Debian Trixie renamed several packages as part of the 64-bit time_t transition and library version bumps. For example: libicu72 became libicu76, libmagickwand-6.q16-6 became libmagickwand-7.q16-10, and libzip4 became libzip5. Always verify package names against the base image’s Debian version.
Q5. How do I find the correct runtime library names if I change the base image?
A. Run a temporary container and use ldd to check for missing libraries:
docker run --rm your-image bash -c \
"for so in /usr/local/lib/php/extensions//.so; do \
ldd \$so 2>/dev/null | grep 'not found'; \
done"
Then search for the correct package with apt-cache search .
Q6. What does validate_timestamps=0 mean for deployments?
A. OPcache will never check if PHP files on disk have changed. This avoids filesystem stat calls on every request (faster). The tradeoff: after deploying new code, you must restart PHP-FPM (docker restart ) to pick up changes.
Q7. Why create a non-root user?
A. Running as root inside a container means a compromised application has full control over the container filesystem. A non-root user limits the damage.
Q8. Can I add Xdebug for local development?
A. Yes, but do not include it in the production image. Use a separate development stage or a Docker Compose override file that installs Xdebug on top of the production image.
9. Key Takeaways
- Multi-stage builds separate build-time complexity from runtime simplicity.
- install-php-extensions eliminates the manual dance of installing dev packages, compiling, and cleaning up.
- Always match runtime library package names to the base image’s Debian version (Trixie uses libicu76, libzip5, libmagickwand-7.q16-10, libavif16).
- OPcache with validate_timestamps=0 is a significant performance win for production.
- Security hardening (expose_php=Off, display_errors=Off, allow_url_fopen=Off) should be the default, not an afterthought.
- Non-root user + health check + .dockerignore round out a production-ready setup.
- Use ldd to diagnose missing shared libraries when extensions fail to load.
10. Conclusion
This multi-stage Dockerfile provides a clean, repeatable way to build a PHP 8.4-FPM production image with everything needed for WordPress and Drupal projects. The builder stage handles all the heavy lifting - compiling extensions, installing Composer, WP-CLI, Drush, Drupal Console, and Node.js - while the production stage starts fresh and copies only what is needed at runtime. Combined with OPcache tuning, security hardening, a non-root user, and a health check, the result is an image that is smaller, faster, and more secure than a traditional single-stage build. When paired with Nginx via Docker Compose, it forms a solid foundation for deploying PHP applications in any environment.
About the Author:Rajan is a DevOps Engineer at AddWebSolution, specializing in automation infrastructure, Optimize the CI/CD Pipelines and ensuring seamless deployments.






Top comments (0)