Shrink PHP Docker Images

Khimananda Oli 10 min read Programming and Languages
Shrink PHP Docker Images

By Khimananda Oli | Last reviewed: August 2026

Bloated containers are a silent tax on your infrastructure budget and deployment velocity. When you fail to shrink PHP Docker images, you accumulate storage costs, slow down CI/CD pipelines, and expand your security attack surface unnecessarily. In my experience auditing production environments across Nepal and globally, teams often deploy 1GB+ PHP containers when a hardened 150MB artifact would suffice. This guide provides the exact multi-stage patterns and base image strategies I use to deliver lean, compliant, and fast-deploying PHP applications.

How do multi-stage builds shrink PHP Docker images?

Multi-stage builds are the single most effective technique to reduce container footprint. The core problem with naive Dockerfiles is that build tools (compilers, headers, dev libraries) persist in the final layer even though your application only needs the compiled binaries at runtime. By separating these concerns, you discard hundreds of megabytes of unnecessary tooling.

BUILD STAGEgcc, make, autoconflibpng-dev, libzip-devcomposer install~800 MB (discarded)COPY --from=buildRUNTIME STAGEphp:8.4-fpm-alpineCompiled .so files onlyvendor/ (no-dev)~120 MB (final)SAVINGS85%Smaller attack surfaceFaster deploysLower egress costsKey Principle:Only COPY specific artifacts between stages. Never copy entire directories blindly.Build stage can use debian-based images for compatibility; runtime uses Alpine for size.Composer dev dependencies (--dev) are excluded from the final COPY operation.System packages installed in build stage do NOT carry over to runtime stage.Each stage has its own independent filesystem and package cache.
Multi-stage build architecture isolating heavy build tools from the minimal runtime environment to shrink PHP Docker images

In practice, this pattern works because PHP extensions like gd, intl, and zip require C compilers and development headers during installation but only need the resulting shared objects (.so files) at runtime. Without multi-stage builds, those compilers remain in your production image permanently.

Implementing the build stage correctly

Your build stage should start from a full-featured base to avoid compilation headaches, then selectively copy only what the runtime needs. Here is a proven pattern for Laravel or similar frameworks:

# Build stage - optimized for compatibility, not size
FROM php:8.4-cli-bookworm AS builder

# Install system deps, compile extensions, then cleanup in ONE layer
RUN apt-get update && apt-get install -y \
    git unzip libzip-dev libpng-dev libicu-dev \
    && docker-php-ext-install zip gd intl opcache \
    && rm -rf /var/lib/apt/lists/*

# Install Composer and application dependencies
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --ignore-platform-reqs

COPY . .
RUN composer dump-autoload --optimize --no-dev

Notice the deliberate ordering: system packages first (cached unless Dockerfile changes), then Composer binary (stable), then dependency files (changes less frequently than source code). This maximizes build caching efficiency while keeping layers minimal.

Constructing the minimal runtime stage

The runtime stage starts fresh from an Alpine base and pulls only compiled artifacts from the builder:

# Runtime stage - minimal Alpine base
FROM php:8.4-fpm-alpine AS production

# Runtime-only system dependencies (no -dev packages)
RUN apk add --no-cache \
    nginx \
    libpng \
    libzip \
    icu-libs \
    && addgroup -S appgroup && adduser -S appuser -G appgroup

# Copy ONLY compiled extensions 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 application without dev dependencies
WORKDIR /var/www/html
COPY --from=builder --chown=appuser:appgroup /app/vendor ./vendor
COPY --from=builder --chown=appuser:appgroup /app/public ./public
COPY --from=builder --chown=appuser:appgroup /app/bootstrap ./bootstrap
COPY --from=builder --chown=appuser:appgroup /app/config ./config

USER appuser
EXPOSE 8080
CMD ["php-fpm"]

This approach consistently produces images under 150MB for typical Laravel applications. If you are managing databases alongside your PHP stack, understanding MySQL performance tuning helps ensure your database container sizing complements your lean PHP images rather than negating their benefits.

Why choose Alpine over Debian for PHP containers?

The choice between Alpine and Debian (bookworm/bullseye) base images represents the largest single factor in final image size. Alpine Linux uses musl libc instead of glibc and BusyBox instead of GNU coreutils, resulting in a base image roughly 5MB versus Debian's 80MB+. For PHP specifically, the difference compounds because every shared library linked against musl is significantly smaller.

CriteriaAlpine (php:8.4-fpm-alpine)Debian (php:8.4-fpm-bookworm)
Base image size~5 MB~82 MB
Final PHP-FPM size (minimal)~95 MB~420 MB
C standard librarymusl libcglibc
Package managerapk (fast, no cache by default)apt (slower, requires manual cleanup)
Extension compatibilityOccasional issues with proprietary libsNear-universal compatibility
Security patch cadenceFast, community-drivenEnterprise-backed, predictable
DNS resolution behaviormussl resolver (can differ)Standard glibc resolver
Best forProduction microservices, edgeLegacy apps, proprietary extensions

A common mistake is avoiding Alpine due to outdated musl compatibility fears. In 2026, virtually all mainstream PHP extensions compile cleanly on Alpine. The exceptions are rare proprietary drivers or legacy PECL packages that hard-code glibc assumptions. Always test with your actual extension set before dismissing Alpine.

Handling Alpine-specific quirks

When migrating from Debian to Alpine, account for three differences that catch engineers off guard:

  • Package names differ: libzip-dev becomes libzip-dev (same name, different repo), but libxml2-dev becomes libxml2-dev. Always verify with apk search.
  • No bash by default: Alpine ships with ash. Add bash explicitly if your entrypoint scripts require it, or rewrite shebangs to #!/bin/sh.
  • Timezone data is separate: Install tzdata package if your application relies on timezone conversions. Missing this causes silent UTC fallbacks.

For teams operating in Nepal or serving South Asian users, remember that timezone handling affects billing cycles, log timestamps, and scheduled jobs. Verify date_default_timezone_set() behaves identically in Alpine before deploying to production.

Which PHP extensions actually belong in production?

Every extension you install adds binary size, memory overhead, and potential vulnerability surface. Audit your requirements ruthlessly. Most Laravel applications need only: opcache, pdo_mysql (or pdo_pgsql), mbstring, tokenizer, xml, ctype, json, bcmath, and optionally redis or gd.

Extension Needed?Is it in php:8.4-fpm-alpine default?YESNOAlready included ✓Zero additional size costInstall via install-php-extensionsRequires system -dev packages?YESNOUse MULTI-STAGE BUILDCompile in builder, copy .so to runtimeDirect install OKNo build tools retainedPro Tip: Use mlocati/install-php-extensionsAutomatically installs + removes build deps in single layerHandles Alpine/Debian differences transparentlyReduces human error in dependency cleanup
Extension selection decision tree ensuring only necessary components are included when you shrink PHP Docker images

The mlocati/docker-php-extension-installer script deserves special mention. It automatically detects your base image variant, installs required system dependencies, compiles the extension, and removes build artifacts in a single invocation. This eliminates the most common source of bloat: forgotten rm -rf /var/cache/apk/* commands.

# Preferred method for Alpine
ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN chmod +x /usr/local/bin/install-php-extensions \
    && install-php-extensions redis gd intl opcache bcmath \
    && rm /usr/local/bin/install-php-extensions

This script also handles pecl configuration flags correctly, which manual docker-php-ext-install calls frequently get wrong for extensions like imagick or xdebug (which should never appear in production images anyway).

How does layer ordering affect final image size?

Docker layers are immutable and additive. A 500MB file added in layer 3 and deleted in layer 7 still occupies 500MB in your image history and contributes to pull times. Every instruction creates a new layer, so combining related operations prevents intermediate bloat from persisting.

Correct vs incorrect cache cleanup

This is where most engineers lose 50-200MB unintentionally:

# WRONG - cache persists in previous layer
RUN apk add --update libpng-dev
RUN make && make install
RUN apk del libpng-dev
RUN rm -rf /var/cache/apk/*

# RIGHT - everything in one layer
RUN apk add --no-cache libpng-dev \
    && make && make install \
    && apk del libpng-dev \
    && rm -rf /var/cache/apk/*

The --no-cache flag in apk prevents index download entirely, unlike apt where you must manually delete /var/lib/apt/lists/*. For Debian-based build stages, always chain update, install, and cleanup in a single RUN instruction.

Leveraging .dockerignore aggressively

Your build context sends files to the daemon before any Dockerfile instruction executes. Large vendor directories, git history, logs, and test fixtures waste time and can accidentally leak into layers via overly broad COPY commands. A proper .dockerignore should exclude:

  • .git/ (entire repository history)
  • vendor/ (rebuilt inside container)
  • node_modules/ (if frontend built separately)
  • *.log, storage/logs/
  • tests/, phpunit.xml
  • .env, .env.* (secrets handled via runtime injection)
  • docker-compose*.yml, Dockerfile*

If you are integrating observability into your PHP stack, ensure your OpenTelemetry instrumentation does not inadvertently include debug artifacts in production images. Refer to instrumenting apps with OpenTelemetry for patterns that keep telemetry lightweight and production-safe.

What security trade-offs come with smaller images?

Shrinking images improves security posture by reducing attack surface, but introduces operational considerations. Alpine's musl libc has historically had subtle behavioral differences from glibc in areas like DNS resolution order, thread-local storage, and certain POSIX edge cases. These rarely affect web applications but can surprise teams running async workers or complex cryptographic operations.

From a compliance perspective (SOC 2, ISO 27001), smaller images simplify vulnerability scanning and reduce false positives. Fewer packages mean fewer CVE matches and faster remediation cycles. However, you must document your base image selection rationale and maintain evidence of regular security updates. Automated scanning with tools like Trivy or Grype should gate your CI pipeline regardless of base image choice.

Non-root execution is non-negotiable for production PHP containers. Both Alpine and Debian support this, but Alpine's adduser/addgroup syntax differs from Debian's useradd. Always create a dedicated unprivileged user and set USER before your CMD instruction. File ownership must match via COPY --chown to prevent permission errors at runtime.

OPTIMIZED IMAGESize: 120 MBAlpine + multi-stage + selective extensionsCVE Surface: Low~40 packages scanned, fast remediationDeploy Time: 8 secondsFast pulls, quick rollbacksCompliance: Audit-friendlyClear SBOM, minimal evidence scopeTrade-off: musl testing requiredValidate DNS, crypto, threading behaviorTrade-off: Debugging harderFewer diagnostic tools availableCost: Minimal egress/storageSignificant savings at scaleBLOATED IMAGESize: 980 MBDebian + all extensions + build toolsCVE Surface: High300+ packages, slow triage cyclesDeploy Time: 45 secondsSlow pulls, delayed incident responseCompliance: Audit burdenLarge SBOM, many justification docsAdvantage: glibc compatibilityWorks with proprietary/binary depsAdvantage: Rich debugging toolsstrace, gdb, full coreutils availableCost: Significant egress/storageMultiplied across environments/regions
Side-by-side comparison of optimized versus bloated PHP containers highlighting security, performance, and compliance implications

For teams needing deeper insight into container runtime behavior after optimization, understanding metrics, logs, and traces ensures your observability strategy adapts to leaner images without losing diagnostic capability.

Start Shrinking Your PHP Images Today

The techniques covered here—multi-stage builds, Alpine bases, selective extensions, disciplined layering, and security-conscious defaults—are not theoretical. They are battle-tested patterns I deploy across production systems serving millions of requests. Start with the multi-stage template provided, measure your current image size with docker images, and iterate. Most teams achieve 60-80% reduction within a single afternoon of focused work.

If your PHP infrastructure needs a comprehensive review, or you want help implementing these patterns in a regulated environment, reach out directly. I help teams build container strategies that are fast, secure, and audit-ready from day one.

Frequently Asked Questions

Alpine Linux remains the smallest option at roughly 50MB. However, Debian Bookworm slim offers better glibc compatibility for complex extensions while staying under 80MB, making it a safer default for production Laravel applications requiring system libraries.

Yes. Multi-stage builds separate compilation dependencies from runtime artifacts. You install build tools and dev headers in the builder stage, compile extensions, then copy only the resulting shared objects to the final runtime stage, often reducing total image size by over sixty percent.

GD, Imagick, and Intl typically add the most weight due to underlying C libraries like libpng, ImageMagick, and ICU. Audit your composer.lock file strictly and remove unused extensions to prevent unnecessary binary dependencies from inflating your final container layer size significantly.

Combine installation and cleanup commands using && operators within one RUN instruction. Execute apt-get update, install packages, and run rm -rf /var/lib/apt/lists/* together to ensure package metadata never persists in intermediate layers or the final image filesystem.

Generally no. UPX can corrupt dynamically linked PHP extensions or cause segmentation faults during runtime. Focus on removing unused dependencies and optimizing layers instead, as binary compression offers minimal gains compared to the stability risks involved with interpreted language runtimes.

Yes. Static PHP builds bundle the interpreter and required extensions into a single executable without external shared libraries. This eliminates glibc dependencies entirely, allowing you to run PHP on scratch or distroless bases for extremely minimal container footprints under twenty megabytes.

Use dive to inspect individual layer contents and identify large files or wasted space. It visualizes the filesystem tree per layer, helping you pinpoint exactly which package installations or configuration steps contribute most to your overall image bloat.

Always. This flag prevents apt from installing suggested but non-essential packages that accompany your requested dependencies. Omitting this single argument frequently adds hundreds of megabytes of unnecessary documentation, locales, and utilities to your final production container image.

Running composer dump-autoload --optimize-autoloader --classmap-authoritative generates efficient class maps and removes development metadata. While primarily improving runtime performance, this also strips unnecessary source files and test directories when combined with proper .dockerignore rules during the build context transfer.

Alpine uses musl libc instead of glibc, requiring recompilation of many extensions against different system libraries. If you install precompiled packages via apk alongside PECL builds, duplicate library sets may exist. Verify extension linking and prefer official Alpine PHP packages where available.

Only if done in the same layer they were created. Deleting files in subsequent layers merely marks them as deleted in overlay filesystems without reclaiming space. Create temporary files in dedicated volumes or combine creation and deletion within identical RUN instructions.

Yes, but with caveats. Distroless containers lack shells and package managers, complicating debugging and cron jobs. They work best for stateless API services where you embed all dependencies during build time and rely entirely on external observability tooling for troubleshooting.

Configure .dockerignore to exclude vendor tests, markdown files, and CI configs before copying source code. Additionally set COMPOSER_NO_DEV=1 environment variable during installation to skip development dependencies entirely, preventing test suites and debugging tools from entering production images.

Aim for under 150MB for standard deployments including Nginx and PHP-FPM. Images exceeding 300MB usually contain unoptimized layers, development dependencies, or redundant system libraries that should be removed through multi-stage builds and strict dependency auditing.

Minimally. The OPcache extension itself adds only a few megabytes, but preloading classes can slightly increase memory allocation at startup. The performance benefits far outweigh negligible storage costs, so always enable OPcache in production containers regardless of size optimization goals.