
September 09, 2026
13 min read
By Kokil Thapa | Last reviewed: September 2026
Your container registry is full of 900 MB images that only need 120 MB to run. Multi-Stage Docker Builds for Small Images fix that by separating build-time dependencies from what actually ships to production. You compile assets in one stage, copy the result into a minimal runtime stage, and discard compilers, dev packages, and source trees. If you deploy Laravel apps with Docker multi-stage patterns for production, the payoff shows up immediately: faster pulls, cheaper registry storage, and fewer packages for attackers to exploit.
FROM stages in one Dockerfile—build tools stay in early stages, and only compiled artifacts and runtime files copy into the final stage, often cutting image size by 60–90%.What are multi-stage Docker builds and why do they produce smaller images?
A multi-stage Dockerfile chains two or more isolated filesystem layers. Each stage starts with its own FROM instruction. Only files you explicitly COPY --from= reach the final image. Everything else—Node modules used for Vite, Composer dev dependencies, GCC, Git—stays behind and never enters the registry tag you deploy.
Single-stage builds are the default mistake. You start from php:8.5-cli, install extensions, run composer install, run npm ci && npm run build, and tag the result. The running container only needs PHP-FPM, opcache, and your compiled public/build assets. It does not need npm, node headers, or /root/.composer/cache.
The mechanism is simple but easy to get wrong. Docker executes every stage during docker build. Intermediate stages become dangling images unless you name and reuse them. The final stage becomes your tagged image. Named stages with AS builder make COPY --from=builder readable and debuggable.
On production Laravel systems I maintain, image size directly affects deploy time. A 200 MB image pulls in seconds on a modest VPS. An 800 MB image blocks rolling updates while CI waits. Smaller images also mean less disk pressure when you keep three release tags per environment. That matters on budget hosting common for Linux server administration in Nepal where disk and bandwidth cost real money.
How do you write a multi-stage Dockerfile for Laravel and PHP applications?
Most PHP web apps need three logical stages: frontend asset compilation, Composer dependency resolution, and a slim PHP-FPM runtime. Laravel 13 on PHP 8.3 or 8.5 fits this pattern cleanly. You can also pre-build assets in CI and skip the Node stage—more on that trade-off later.
Stage 1: Build frontend assets with Node.js
Use the Node 26 LTS image only for compiling Vite 8.x output. Copy package.json and lockfile first for layer caching. Run npm ci, copy source, then npm run build.
# syntax=docker/dockerfile:1
FROM node:26-alpine AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY resources/ resources/
COPY vite.config.js ./
RUN npm run build Stage 2: Install PHP dependencies with Composer
Use a Composer 2.10 image or the official PHP CLI image with Composer copied in. Install production dependencies only. Skip dev packages with --no-dev unless you genuinely need them at runtime.
FROM composer:2.10 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-interaction \
--prefer-dist \
--optimize-autoloader \
--no-scripts
COPY . .
RUN composer dump-autoload --optimize Stage 3: Assemble the runtime image
Start from a minimal PHP-FPM base. Copy vendor from the Composer stage and compiled assets from the frontend stage. Run as a non-root user where possible.
FROM php:8.5-fpm-alpine AS runtime
RUN apk add --no-cache \
libzip-dev icu-dev oniguruma-dev \
&& docker-php-ext-install pdo_mysql zip intl opcache
WORKDIR /var/www/html
COPY --from=vendor /app/vendor ./vendor
COPY --from=vendor /app/bootstrap ./bootstrap
COPY --from=vendor /app/app ./app
COPY --from=vendor /app/config ./config
COPY --from=vendor /app/routes ./routes
COPY --from=vendor /app/public ./public
COPY --from=frontend /app/public/build ./public/build
COPY . .
RUN chown -R www-data:www-data storage bootstrap/cache
USER www-data
EXPOSE 9000
CMD ["php-fpm"] That Dockerfile mirrors what I use on booking platforms like Adventure Third Pole Trek where deploy speed affects how quickly you can patch production. Pair it with an external Nginx container or reverse proxy—do not bundle Nginx inside the PHP image unless you have a specific reason.
Validate the image before you push. Run these commands locally or in CI:
docker build -t myapp:multi .— confirm all stages complete without error.docker images myapp:multi— compare size against your old single-stage tag.docker run --rm myapp:multi php artisan --version— verify Laravel boots.docker history myapp:multi— inspect which layers contribute the most bytes.
If your server has no Node.js—and many production VPS setups do not—build assets in CI and copy only the artefact. That is the pattern I follow with Deployer 7 pipelines where the build runner has npm 12 and the target server receives pre-compiled files. The Dockerfile shrinks to two stages: Composer and runtime.
What is the difference between single-stage and multi-stage Docker builds?
The difference is not syntax alone. It is what ends up in the artifact you deploy. Single-stage images contain every command you ran during build. Multi-stage images contain only what you copied forward.
| Criteria | Single-Stage Build | Multi-Stage Build |
|---|---|---|
| Typical Laravel image size | 700 MB – 1.2 GB | 120 MB – 250 MB |
| Build tools in production | Node, Composer, Git, GCC often present | Excluded unless explicitly copied |
| Layer caching | One chain; small changes invalidate late layers | Per-stage cache; frontend and vendor rebuild independently |
| Security surface | Larger; more packages to patch | Smaller; easier to scan and harden |
| Dockerfile complexity | Lower initially | Higher; pays off after first production deploy |
| CI build time | Often shorter (one stage) | Slightly longer; parallel stage caching helps |
| Best fit | Local dev prototypes | Production, staging, and registry-hosted images |
The table reflects real numbers from Laravel apps with Vite frontends. Exact sizes vary by extension count and base image choice. Alpine-based runtimes run smaller but occasionally break on musl compatibility with certain PHP extensions. Debian-slim variants trade a few megabytes for fewer surprises.
For deeper size reduction after multi-staging, consider distroless runtime images or stripping docs and man pages in the final stage. Scan every tag with Trivy container scanning before deploy—smaller images scan faster, but fewer packages does not mean zero CVEs.
Which base images and patterns produce the smallest final images?
Base image choice often matters more than stage count. Picking php:8.5-fpm-alpine over php:8.5-fpm (Debian) saves hundreds of megabytes before you optimize anything else.
Alpine versus Debian-slim
Alpine uses musl libc and BusyBox. Images are tiny. Some PHP extensions and PECL modules compile painfully or fail outright on musl. Debian-slim uses glibc and behaves closer to your Ubuntu 22/24 production servers. For client projects where I cannot afford build surprises, Debian-slim is the safer default. For internal services where I control extensions, Alpine wins on size.
Pre-built extension images
Community images like serversideup/php ship common extensions pre-installed. That removes repeated docker-php-ext-install layers across projects. Evaluate trust and update cadence before adopting third-party bases in production.
.dockerignore is not optional
Multi-staging cannot help if you copy node_modules, .git, and tests/ into every stage. A strict .dockerignore keeps build context small and prevents accidental secret leakage.
.git
.github
node_modules
vendor
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
.env
.env.*
tests
phpunit.xml
docker-compose*.yml
README.md
*.md Pair this with BuildKit cache mounts for Composer and npm. The syntax directive at the top of your Dockerfile enables modern features:
# syntax=docker/dockerfile:1
FROM composer:2.10 AS vendor
RUN --mount=type=cache,target=/tmp/cache \
composer install --no-dev --prefer-dist BuildKit also powers multi-platform builds with Docker Buildx when you need amd64 and arm64 tags from one pipeline. That is increasingly relevant for Apple Silicon dev machines pushing to amd64 cloud servers.
How do you debug and optimize multi-stage Docker builds in CI?
Build failures in stage two often trace back to missing files from stage one. The error message shows which COPY --from= failed. Inspect the intermediate stage directly instead of guessing.
docker build --target frontend -t debug-frontend .
docker run --rm -it debug-frontend sh
ls -la public/build Common production gotchas I have hit on real deployments:
- Missing APP_KEY at build time. Do not bake secrets into images. Pass runtime env vars through orchestrator or
docker composeoverrides. - Running
php artisan config:cacheduring build. That freezes environment-specific values into the image. Cache config at container start instead. - Forgetting storage permissions. The runtime user needs write access to
storage/andbootstrap/cache/. - Copying entire
node_modulesfrom the frontend stage. Copy onlypublic/build. Node modules belong nowhere near production PHP. - Stale BuildKit cache after dependency changes. Bust cache with
--no-cacheonce when debugging weird missing-package errors.
Wire multi-stage builds into your CI pipeline the same way you wire tests. A typical GitLab CI job builds on push, scans with Trivy, and pushes a semver tag. See build pipeline automation best practices for the broader pattern. On legal-tech portals like Court Marriage In Nepal, predictable deploys matter as much as raw image size—broken builds during a deadline week cost more than an extra 50 MB.
Compare your workflow against additional Docker image size reduction techniques including squashing, --squash experimental flags, and removing apt lists in the same RUN layer. Multi-staging is the foundation. Layer hygiene polishes the result.
Resource limits matter during build too. A multi-stage Laravel build can spike RAM when npm and Composer run in parallel CI jobs. Set memory caps with guidance from limiting Docker container resources so OOM kills do not masquerade as mysterious build failures.
If you are new to the toolchain on Ubuntu, start with installing Docker on Ubuntu before adopting multi-stage patterns. The concepts assume BuildKit is enabled—which it is by default in current Docker Engine releases per the official Docker multi-stage build documentation.
When should you choose multi-stage builds over other deployment approaches?
Not every PHP project needs Docker in production. Many sites I maintain still deploy with Deployer 7 over SSH to Apache and PHP-FPM without containers. Multi-stage Docker shines when you need reproducible environments, horizontal scaling with Kubernetes, or consistent staging-to-production parity.
Choose multi-stage containers when:
- Multiple developers need identical runtime stacks without local PHP version drift.
- CI/CD must produce one immutable artefact promoted across environments.
- You run on orchestrators that pull images on every node during scale events.
- Security policy requires minimal production images and regular CVE scanning.
Skip containers—or skip multi-staging—when a single VPS runs one app and Deployer already works. Adding Docker without operational need increases moving parts. For greenfield custom software projects, decide early whether containers are a deployment target or a dev-only convenience. Changing that decision mid-project wastes time.
WordPress and WooCommerce 11.1 sites rarely benefit from custom multi-stage images unless you run headless or API-driven architectures. Standard managed hosting or LAMP stacks remain cheaper for brochure sites. Custom Laravel and API backends are where container size optimization returns the most value. See API development services when your deployment target is container-native from day one.
Frontend tooling choices affect stage one size. Vite 8.x produces smaller output than legacy Webpack pipelines and builds faster in CI. Read Vite versus Webpack for frontend builds if you still carry a Webpack config from an older Laravel mix setup. Smaller compiled assets mean smaller COPY layers even after multi-staging.
For JSON config inspection during debugging, a quick paste into the JSON formatter tool beats squinting at minified CI logs. Small conveniences add up when you are tracing a failed COPY --from at midnight before a client launch.
Key Takeaways
- Split build and runtime into separate
FROMstages; onlyCOPY --fromwhat production actually executes. - For Laravel 13 on PHP 8.5, use Node 26 for Vite, Composer 2.10 for vendor, and php-fpm-alpine or debian-slim for the final stage.
- A strict
.dockerignoreand BuildKit cache mounts cut build time as much as they cut context size. - Inspect intermediate stages with
docker build --targetwhen assets or vendor trees go missing. - Never bake
.envsecrets or environment-specific config cache into the image layers. - Scan slim tags with Trivy anyway—fewer packages helps, but it does not eliminate patch responsibility.
People Also Ask
How many stages should a multi-stage Dockerfile have?
Most web applications need two to four stages. A minimal setup uses one build stage and one runtime stage. Laravel apps with Vite often use three: frontend, Composer, and PHP-FPM runtime. Add a stage only when it has a distinct base image or dependency set. Extra stages without clear separation add complexity without size benefit.
Does multi-stage build reduce image size automatically?
No. Docker discards intermediate stages from the final tag, but only if you never copy bloat forward. Copying node_modules, dev vendor packages, or entire source trees into the runtime stage defeats the purpose. Base image choice and .dockerignore discipline matter equally.
Can you use multi-stage builds with Docker Compose?
Yes. Reference the Dockerfile in your service definition and Compose builds the final stage by default. Use target: runtime under the build key to select a specific stage. For local development you may target a dev stage with Xdebug while production CI targets the slim runtime stage from the same file.
Are multi-stage builds supported in Podman and Buildah?
Yes. Podman and Buildah parse the same Dockerfile syntax including multiple FROM instructions and COPY --from. Teams migrating from Docker can reuse Dockerfiles with minimal changes. BuildKit-specific cache mount syntax may need verification on your builder backend.
Ship smaller images on your next deploy
Multi-Stage Docker Builds for Small Images are the fastest win in container hygiene after picking a sane base image. You keep build tools off production servers, pull tags in seconds instead of minutes, and give security scanners less surface to complain about. Start by converting one staging Dockerfile, measure before-and-after with docker images, and wire the result into CI before touching production.
If you want help dockerizing a Laravel app, tightening an existing pipeline, or moving from SSH deploys to container-based releases, contact us or explore support and maintenance services. For related reading, browse the blog or learn more about my production deployment experience across Nepal and international client projects.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

