
Table of Contents
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.
install-php-extensions, and remove package manager caches in the same layer they were created. This typically reduces final image size by 60–80%.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.
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.
| Criteria | Alpine (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 library | musl libc | glibc |
| Package manager | apk (fast, no cache by default) | apt (slower, requires manual cleanup) |
| Extension compatibility | Occasional issues with proprietary libs | Near-universal compatibility |
| Security patch cadence | Fast, community-driven | Enterprise-backed, predictable |
| DNS resolution behavior | mussl resolver (can differ) | Standard glibc resolver |
| Best for | Production microservices, edge | Legacy 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-devbecomeslibzip-dev(same name, different repo), butlibxml2-devbecomeslibxml2-dev. Always verify withapk search. - No bash by default: Alpine ships with ash. Add
bashexplicitly if your entrypoint scripts require it, or rewrite shebangs to#!/bin/sh. - Timezone data is separate: Install
tzdatapackage 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.
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.
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.