Docker images for PHP applications have a reputation for ballooning past 1 GB. The main culprits are build tools, development packages, and layer cache bloat that never gets trimmed. Multi-stage builds solve this cleanly — you build in one stage and copy only the runtime artefacts into a lean final image.
The Problem
A typical single-stage PHP Dockerfile pulls in Composer, Node, npm, build-essential, and assorted dev headers. All of that ends up in the final image even though none of it is needed at runtime. Our starting point was a 1.2 GB image for a medium Laravel application.
The Multi-Stage Approach
The trick is to separate build dependencies from runtime dependencies. Each FROM statement starts a new stage; the final stage only receives what you explicitly COPY --from.
# Stage 1: Composer dependencies
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts
# Stage 2: Node assets
FROM node:20-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json vite.config.js ./
COPY resources ./resources
RUN npm ci && npm run build
# Stage 3: Runtime image
FROM php:8.3-fpm-alpine
WORKDIR /var/www/html
COPY . .
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
RUN php artisan config:cache && php artisan route:cache
Results
After applying this pattern the image dropped from 1.2 GB to 180 MB — an 85% reduction. Build time stayed roughly the same because Docker layer caching means the vendor and asset stages only rebuild when their respective lock files change.
What to Watch Out For
- Run
composer install --no-scriptsin the vendor stage to avoid running artisan commands before the full app is assembled - Don't copy
.envinto the image — inject environment variables at runtime - The
php artisan config:cachein the runtime stage bakes your config at build time; make sure all required env vars are set in your CI environment