Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Multi-Stage Docker Builds for Small Images

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.

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.

Single-Stage vs Multi-Stage ImagesSingle-Stage (bloated)Build tools + runtimeDev dependenciesSource + cache (~850 MB)Large attack surfaceMulti-Stage (slim)Stage 1: build assetsStage 2: runtime onlyCopied artifacts (~140 MB)Minimal attack surfaceCOPY --from=builder only what runs
Multi-stage Docker builds for small images discard build-time layers and ship only runtime artifacts to production.

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.

Laravel Multi-Stage Build PipelineStage 1: Node 26npm ci + Vite buildpublic/build outputStage 2: Composercomposer installvendor/ autoloadStage 3: PHP 8.5php-fpm-alpineruntime onlyFinal Image ContentsCOPY --from=frontend public/buildCOPY --from=vendor vendor + app codeNo Node, no Composer, no dev tools
A typical Laravel multi-stage pipeline: Vite compiles assets, Composer resolves PHP packages, and PHP-FPM runs the slim final image.

Validate the image before you push. Run these commands locally or in CI:

  1. docker build -t myapp:multi . — confirm all stages complete without error.
  2. docker images myapp:multi — compare size against your old single-stage tag.
  3. docker run --rm myapp:multi php artisan --version — verify Laravel boots.
  4. 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.

CriteriaSingle-Stage BuildMulti-Stage Build
Typical Laravel image size700 MB – 1.2 GB120 MB – 250 MB
Build tools in productionNode, Composer, Git, GCC often presentExcluded unless explicitly copied
Layer cachingOne chain; small changes invalidate late layersPer-stage cache; frontend and vendor rebuild independently
Security surfaceLarger; more packages to patchSmaller; easier to scan and harden
Dockerfile complexityLower initiallyHigher; pays off after first production deploy
CI build timeOften shorter (one stage)Slightly longer; parallel stage caching helps
Best fitLocal dev prototypesProduction, 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.

Typical Laravel Image Sizes (MB)Single-stage Debian920Multi-stage Debian210Multi-stage Alpine135Multi + distroless98Savings: faster pull, less disk, quicker scansNumbers vary by extensions and app code size
Multi-stage Docker builds for small images typically cut Laravel production tags from nearly 1 GB to under 250 MB depending on base image choice.

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 compose overrides.
  • Running php artisan config:cache during 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/ and bootstrap/cache/.
  • Copying entire node_modules from the frontend stage. Copy only public/build. Node modules belong nowhere near production PHP.
  • Stale BuildKit cache after dependency changes. Bust cache with --no-cache once 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.

Multi-Stage Build GotchasWrong COPY pathBuild succeeds, app 404s assetsSecrets in image.env copied into layerConfig cached at buildWrong DB host in productionStorage not writable500 errors on first requestFix: test each stage with --targetUse .dockerignore + runtime env injection
Debug multi-stage Docker builds stage-by-stage—the final image failure often originates in an earlier build stage.

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 FROM stages; only COPY --from what 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 .dockerignore and BuildKit cache mounts cut build time as much as they cut context size.
  • Inspect intermediate stages with docker build --target when assets or vendor trees go missing.
  • Never bake .env secrets 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

A multi-stage Dockerfile chains two or more isolated stages, each starting with its own FROM instruction. Docker executes every stage during build, but only files you explicitly COPY --from= named stages reach the final tagged image. Compilers, Node modules, Composer dev dependencies, Git, and GCC stay in intermediate stages and never enter production. On Laravel apps with Vite frontends, the running container needs PHP-FPM, opcache, vendor, and compiled public/build assets—not npm or build headers. That separation is why multi-stage Docker builds for small images typically discard 60–90% of what a single-stage build would ship.

Often 60–90%—from roughly 700 MB–1.2 GB single-stage down to about 120 MB–250 MB multi-stage, depending on extensions and base image.

Use multi-stage containers when multiple developers need identical runtime stacks, CI must produce one immutable artifact promoted across environments, or orchestrators pull images on every scale event. Skip them when a single VPS runs one app and Deployer 7 over SSH to Apache and PHP-FPM already works reliably.

Most Laravel 13 apps on PHP 8.3 or 8.5 need three logical stages. Stage one uses node:26-alpine to run npm ci and npm run build for Vite 8.x output—copy package.json and lockfile first for layer caching. Stage two uses composer:2.10 to run composer install --no-dev --prefer-dist --optimize-autoloader, then dump-autoload. Stage three starts from php:8.5-fpm-alpine, installs only runtime PHP extensions, COPY --from=vendor for application code and vendor, COPY --from=frontend for public/build only, sets www-data ownership on storage and bootstrap/cache, and runs php-fpm. Pair the container with an external Nginx reverse proxy rather than bundling Nginx inside the PHP image unless you have a specific reason.

The difference is what ends up in the artifact you deploy, not syntax alone. Single-stage images contain every command you ran during build—Node, Composer, Git, and GCC often remain present. Multi-stage images contain only what you copied forward. Single-stage Laravel tags commonly hit 700 MB–1.2 GB; multi-stage tags land around 120 MB–250 MB. Multi-stage also gives per-stage layer caching so frontend and vendor rebuild independently when only one changes. Single-stage suits local prototypes; multi-stage suits production, staging, and registry-hosted images where pull speed and security surface matter.

Most web applications need two to four stages. A minimal Laravel setup without a Node build runner uses two: Composer for vendor resolution and a slim PHP-FPM runtime. The full pattern adds a third frontend stage with Node 26 LTS for Vite compilation. You might add a fourth only when a distinct pre-processing step genuinely warrants isolation—extra stages without clear COPY --from= boundaries add complexity without size benefit. Name each stage with AS frontend or AS vendor so COPY --from= references stay readable when you debug failed builds at midnight.

Base image choice often matters more than stage count alone. php:8.5-fpm-alpine saves hundreds of megabytes over the full Debian php:8.5-fpm image before any other optimization. Alpine uses musl libc and BusyBox; some PHP extensions and PECL modules compile painfully or fail on musl. Debian-slim uses glibc and behaves closer to Ubuntu 22/24 production servers—safer when you cannot afford build surprises. Community images like serversideup/php ship common extensions pre-installed, removing repeated docker-php-ext-install layers, but evaluate trust and update cadence before adopting third-party bases in production.

Alpine wins on raw size when you control which PHP extensions you need and have verified they compile cleanly on musl. Debian-slim trades a few megabytes for fewer compatibility surprises—my default on client projects where an extension failure mid-build is unacceptable. The article's example Dockerfile uses php:8.5-fpm-alpine with pdo_mysql, zip, intl, and opcache installed via docker-php-ext-install. If a PECL module fails on Alpine, switch the runtime stage to Debian-slim rather than abandoning multi-staging entirely. Either choice beats shipping Node and Composer in a single-stage tag.

Yes, when your production server or CI pipeline already produces compiled assets. Many production VPS setups have no Node.js installed, which is why I follow a pattern where the GitLab CI build runner with npm 12 runs npm ci and npm run build, then the Dockerfile shrinks to two stages: Composer and runtime. The runtime stage copies pre-built public/build artefacts from the build context or a CI artefact instead of COPY --from=frontend. Never copy entire node_modules from any stage—only public/build belongs in production PHP. Skipping the Node stage saves CI time but requires discipline so deploys never ship stale Vite output.

Multi-staging cannot help if you copy node_modules, .git, tests, and .env into every stage's build context. A strict .dockerignore keeps the context small, speeds builds, and prevents accidental secret leakage into image layers. Exclude at minimum .git, .github, node_modules, vendor, storage logs and cache paths, .env and .env.*, tests, phpunit.xml, docker-compose files, and markdown docs. Pair this with BuildKit cache mounts for Composer and npm using the syntax=docker/dockerfile:1 directive at the top of your Dockerfile. Smaller context plus cache mounts cut build time as much as they cut the bytes Docker sends to the daemon.

Build failures in a later stage often trace to missing files from an earlier one—the error shows which COPY --from= failed. Inspect intermediate stages directly: docker build --target frontend -t debug-frontend . then docker run --rm -it debug-frontend sh and ls -la public/build. Compare final size with docker images and inspect layer contributions via docker history. When dependency changes cause weird missing-package errors, bust stale BuildKit cache once with --no-cache. Wire builds into CI the same way you wire tests—build on push, scan with Trivy, push a semver tag. Resource limits matter too; npm and Composer running in parallel CI jobs can OOM if memory caps are too tight.

Do not bake APP_KEY or other secrets into image layers—pass runtime env vars through your orchestrator or docker compose overrides. Do not run php artisan config:cache during build; that freezes environment-specific values into the image—cache config at container start instead. The runtime user needs write access to storage/ and bootstrap/cache/; forgetting chown on www-data breaks Laravel silently. Copy only public/build from the frontend stage, never node_modules. After dependency changes, stale BuildKit cache can masquerade as missing packages—rebuild with --no-cache once when debugging. Validate locally with docker run --rm myapp:multi php artisan --version before pushing.

Smaller images carry fewer packages for attackers to exploit and scan faster with tools like Trivy, but fewer packages does not mean zero CVEs. Multi-stage builds exclude Node, Composer, Git, and GCC from production unless you explicitly copy them forward—shrinking the patch surface compared to single-stage tags. Scan every tag with Trivy before deploy regardless of size. Running the final stage as a non-root user like www-data adds another layer of hardening. For deeper reduction after multi-staging, consider distroless runtime images or stripping docs and man pages in the final stage, though Alpine or Debian-slim PHP-FPM bases cover most Laravel workloads without that extra complexity.

No, not unless you have a specific operational reason. The article's recommended pattern pairs a slim PHP-FPM runtime container with an external Nginx container or reverse proxy. Bundling Nginx inside the PHP image adds process management complexity, blurs the separation between static file serving and PHP execution, and works against the goal of minimal single-purpose containers. On booking platforms where deploy speed matters, a dedicated PHP-FPM tag plus separate Nginx keeps rolling updates predictable. If you run on Kubernetes or docker compose, two small focused images scale and patch independently—Nginx config changes do not require rebuilding your entire Laravel application layer.

Both approaches keep Node and npm off production servers. Multi-stage Docker produces one immutable artefact—the final image tag—containing vendor, compiled assets, and runtime in a reproducible stack promoted across staging and production. Building assets in CI with Deployer 7 or GitLab CI and deploying over SSH skips container overhead entirely, which many sites I maintain still use successfully on a single VPS. Multi-stage Docker shines when horizontal scaling, Kubernetes, or strict environment parity demands a registry-hosted image. Adding Docker without operational need increases moving parts. For greenfield Laravel projects, decide early whether containers are the deployment target or a dev-only convenience—changing mid-project wastes time.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: