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.

How to Reduce Docker Image Size with Multi-Stage Builds

By Kokil Thapa | Last reviewed: September 2026

Large Docker images slow every pull, inflate registry bills, and widen your attack surface. If you want to know how to reduce Docker image size with multi-stage builds, the fix is structural: separate compile-time dependencies from what actually runs in production. On a Laravel production Docker setup, I routinely see images drop from 900 MB to under 180 MB once build tools and dev packages stay in earlier stages only.

What Is a Docker Multi-Stage Build and Why Does It Shrink Images?

A multi-stage Dockerfile defines several FROM blocks in one file. Each block is a stage with its own filesystem. Only artifacts you explicitly COPY --from= reach the final image. Everything else is thrown away when that stage finishes.

Single-stage images keep the damage permanently. You install GCC, Node.js, and Composer dev tools. They remain in the layer stack forever. Multi-stage builds break that pattern. Build fat, ship thin.

This matters on real projects. A Nepal-based team deploying to a single EC2 instance pays for every megabyte on every release. Smaller images mean faster GitLab CI pulls and quicker rollbacks during incidents.

Multi-Stage Build PipelineStage 1: BuilderNode, ComposerStage 2: VendorPHP deps onlyStage 3: RuntimeSlim PHP-FPMDiscarded: build tools, caches, source mapsOnly copied artifacts land in final imageFinal image: app + runtime only
How to reduce Docker image size with multi-stage builds — builder stages compile; the runtime stage copies only what ships.

The official Docker documentation describes this pattern as the standard way to keep production images minimal while still running complex build steps. The same idea applies whether you ship Laravel 13 on PHP 8.5 or a static frontend built with Vite 8.x.

How Do You Write a Multi-Stage Dockerfile for a Laravel Application?

Laravel apps are a textbook case. You need Node.js 26 LTS to compile frontend assets. You need Composer 2.10 for PHP packages. Neither belongs in the container that runs PHP-FPM behind Apache or Nginx in production.

Step 1: Name your stages explicitly

Named stages make COPY --from= readable and prevent copy-paste errors during refactors. Use names like frontend, vendor, and runtime.

Step 2: Build assets in an isolated Node stage

Install npm 12 dependencies, run npm run build, and copy only the public/build directory forward. The entire Node runtime stays behind.

Step 3: Install Composer dependencies without dev packages

Run composer install --no-dev --optimize-autoloader in a PHP builder stage. Copy the vendor/ tree into runtime. Skip PHPUnit, PHPStan, and other dev-only tools.

Step 4: Assemble a slim PHP-FPM runtime

Start from php:8.5-fpm-alpine or a distroless PHP base. Copy application code, vendor, and compiled assets. Install only runtime extensions such as pdo_mysql and opcache.

# 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

FROM composer:2.10 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
    --no-dev \
    --no-interaction \
    --prefer-dist \
    --optimize-autoloader

FROM php:8.5-fpm-alpine AS runtime
RUN apk add --no-cache \
    libpng-dev libzip-dev oniguruma-dev \
    && docker-php-ext-install pdo_mysql zip opcache

WORKDIR /var/www/html
COPY --from=vendor /app/vendor ./vendor
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"]

On production Laravel applications I maintain, committing built assets and skipping Node on the server is another valid pattern. Multi-stage builds still help when CI must produce the image from a clean Git checkout every time. See the companion guide on build pipeline automation best practices for wiring this into GitLab CI.

Which Base Image Choices Make the Biggest Size Difference?

Your final FROM line often matters more than clever layer ordering. A full Debian-based PHP image can weigh 400 MB before your app code arrives. Alpine or distroless bases cut that dramatically.

Base image typeTypical sizeBest forTrade-offs
php:8.5-fpm (Debian)350–450 MBQuick prototypes, broad extension supportLarge; includes apt tooling
php:8.5-fpm-alpine80–120 MBLaravel, Symfony, API servicesmusl libc; some PECL extensions harder
Distroless / scratch + static binary20–60 MBGo, Rust, minimal PHP via static buildsNo shell; harder to debug
Single-stage with build tools800 MB–1.2 GBLocal dev onlyNever ship to production

For most PHP workloads in 2026, Alpine plus multi-stage copy is the sweet spot. If you need deeper hardening after slimming, read about distroless images for security and decide whether the operational cost is worth it for your team size.

Image Size: Single vs Multi-StageSingle-Stage920 MBNode + GCC + dev depsMulti-Stage165 MBRuntime files onlySavings82% smallerFaster pull and deploy
Typical Laravel image size reduction after switching to multi-stage builds with an Alpine runtime base.

What Layer and Cache Tricks Further Reduce Docker Image Size?

Multi-stage builds remove whole toolchains. Layer hygiene removes waste inside each stage. Both together produce images that pull in seconds on a Kathmandu office connection or a constrained CI runner.

Order Dockerfile instructions from least to most volatile

Copy dependency manifests first. Run install commands. Copy application source last. Docker reuses cached layers when lock files do not change. That speeds builds and avoids accidental re-downloads.

Combine RUN instructions and clean package caches in the same layer

Every RUN apt-get install without cleanup in the same layer leaves cache files behind forever. Use one RUN with install and purge chained together.

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        git unzip libpng-dev \
    && docker-php-ext-install gd \
    && apt-get purge -y git \
    && apt-get autoremove -y \
    && rm -rf /var/lib/apt/lists/*

Use .dockerignore aggressively

Exclude node_modules, vendor, .git, test suites, and local .env files from the build context. Smaller context means faster uploads to the daemon and fewer accidental layer bloat entries.

.git
node_modules
vendor
tests
storage/logs
.env
*.md
docker-compose*.yml

Do not copy files you do not need at runtime

Skip README files, CI configs, and frontend source if only compiled assets matter. Use explicit COPY lists instead of COPY . . when the app layout is stable.

  1. Audit the final image with docker history --no-trunc IMAGE.
  2. Inspect layer contents with dive IMAGE or docker export plus tar.
  3. Remove debug symbols and source maps from production asset copies.
  4. Pin base image digests for reproducible builds across environments.
  5. Scan the slim image with Trivy before tagging a release candidate.

Container scanning catches vulnerabilities that slimming exposes rather than fixes. Pair size work with the guide on container image scanning with Trivy before you promote images to production.

How Do You Verify and Deploy Slim Multi-Stage Images in Production?

Building a small image is half the job. You still need confidence that the runtime stage behaves identically to what passed QA. Treat verification as part of the Dockerfile design, not an afterthought.

Compare image sizes before and after

docker build -t myapp:multi -f Dockerfile .
docker images myapp:multi --format "{{.Repository}}:{{.Tag}} {{.Size}}"

docker build -t myapp:single -f Dockerfile.single .
docker images myapp:single --format "{{.Repository}}:{{.Tag}} {{.Size}}"

Log both numbers in your CI artifact summary. Teams notice regressions fast when the metric is visible on every merge request.

Smoke-test the runtime stage, not the builder

Run health checks against the final tagged image. Confirm PHP extensions load, database connections work, and queue workers start. A missing ext-intl or ext-redis extension often surfaces only at this step.

Tag and promote deliberately

Use immutable tags for releases and mutable tags like latest only for dev environments. Consistent tagging prevents rollback confusion when a slim image replaces a bloated one mid-incident. The article on Docker image tagging strategies covers patterns that work well with GitLab CI and Deployer-style deploys.

Production Deploy FlowGit PushMulti-StageBuildTrivy ScanRegistryDeployFinal image ~165 MB — pull under 30s on typical VPS linksRollback: previous digest still in registryZero-downtime symlink swap on Ubuntu host
Production workflow for slim multi-stage Docker images — build, scan, push, deploy with digest-based rollback.

Several sister sites I operate share a Deployer 7 plus GitLab CI pipeline on Ubuntu EC2 hosts. Slim images reduced average deploy time because each release pulls less data over SSH-tunnelled registry access. If your team lacks in-house DevOps capacity, Linux system administration support can cover server hardening alongside container rollout.

For local parity, use Docker Compose multi-container setup with a dev target that keeps Node and Xdebug available. Production stays multi-stage and minimal. Dev stays comfortable. That split is intentional.

What Common Mistakes Undo Your Multi-Stage Size Gains?

Teams adopt multi-stage builds and still ship 600 MB images. The pattern is present. The discipline is not. These failures show up repeatedly on client audits.

  • Copying the entire builder stage. A blanket COPY --from=builder / / defeats the purpose. Copy paths explicitly.
  • Installing dev dependencies in the runtime stage. Never run composer install without --no-dev in production stages.
  • Leaving package managers in runtime. apt, apk, and npm binaries belong in builder stages only.
  • Ignoring .dockerignore. Uploading a 400 MB node_modules folder into context wastes time and risks stale copies.
  • Using latest tags on base images. Pin digests or minor version tags for reproducible slim builds.
  • Skipping extension audits. Alpine images need explicit apk add for libraries behind PHP extensions.

Resource limits matter too. A slim image still misbehaves if the container has no memory cap. Set CPU and memory bounds as described in limit Docker container resources once the image lands on your host.

When to Use Multi-Stage BuildsNeed compiled assets?YesNoUse multi-stageStatic binary orsingle-language app?Node + PHP + assetsMaybe single-stagewith slim baseNever ship dev tools to prod
Decision guide for when multi-stage builds are worth the Dockerfile complexity.

WordPress and WooCommerce 11.1 projects follow the same logic. Build custom themes with Node in one stage. Copy only compiled CSS and JS into a PHP-Apache runtime. Magento 2.4.x storefronts benefit even more because frontend compilation chains are longer. For enterprise stacks, enterprise application development workflows often standardise on one multi-stage template per framework.

On booking platforms like Adventure Third Pole Trek, Laravel plus Livewire assets compile in CI. The production container never installs Vite or webpack. That keeps the running footprint small on shared hosting budgets common among Nepal tour operators.

If you are starting from bare metal, install Docker on Ubuntu first using the Install Docker on Ubuntu walkthrough. Then adopt multi-stage patterns before your first production push.

Frontend toolchain choices affect builder stage size. Compare bundlers in the Vite vs Webpack for frontend builds article before locking your Node stage Dockerfile.

Validate JSON config files copied into images with the free JSON formatter tool during local prep. Bad config should fail before it reaches a registry.

Packer complements Docker when you need AMI or VM images alongside containers. See Packer build machine images for hybrid infrastructure patterns.

Post-deploy, ongoing support and maintenance should include quarterly image audits. Base images receive security patches. Rebuild slim stages on schedule, not only when features ship.

Performance testing belongs in the same release train. Testing and optimization services catch regressions when a slim image accidentally drops a caching extension or opcode config.

For eCommerce workloads such as Quick And Easy Nepalese Grocery, smaller images speed horizontal scaling during festival-season traffic spikes. Dashain and Tihar order surges are predictable. Image pull time should not be the bottleneck.

Read more DevOps content on the blog index or learn about the author on the about me page. Browse the full project portfolio for other production Laravel deployments that rely on disciplined container practices.

Key Takeaways

  • Split compile-time and runtime into separate Dockerfile stages; copy only artifacts with COPY --from=.
  • Choose Alpine or distroless final bases instead of full Debian images when extensions allow it.
  • Run composer install --no-dev and npm ci in builder stages; never in production runtime.
  • Use .dockerignore, combined RUN layers, and explicit COPY lists to avoid hidden bloat.
  • Measure with docker images and dive, then scan slim tags with Trivy before deploy.
  • Pin base image versions and keep dev Compose targets separate from production multi-stage files.

People Also Ask

How much smaller can a multi-stage Docker image get?

Most Laravel and Node-plus-PHP stacks shrink 60–90% compared to single-stage builds. A typical improvement runs from roughly 900 MB down to 150–200 MB when you drop Node, GCC, and dev Composer packages from the final layer.

Can you use multi-stage builds with Docker Compose?

Yes. Reference the final stage with target: runtime in your Compose build config. Keep a separate dev Dockerfile or target that includes debugging tools. Production Compose files should build only the slim runtime stage.

Do multi-stage builds slow down CI pipelines?

First builds can take longer because multiple stages run sequentially. Cached layers usually make subsequent builds faster than single-stage equivalents. The net win shows up at deploy time when registry pulls transfer far less data.

Are multi-stage builds enough for production security?

They reduce attack surface by removing shells and build tools, but they are not a complete security strategy. Pair slim images with regular base-image rebuilds, non-root users, read-only filesystems where possible, and vulnerability scanning on every tag.

Ship Smaller Images on Your Next Release

Knowing how to reduce Docker image size with multi-stage builds changes every downstream metric: faster deploys, lower registry storage, quicker disaster recovery, and fewer packages for attackers to probe. Start with one Laravel or API service Dockerfile, measure the before-and-after size, wire the build into CI, and roll the pattern across remaining services once the smoke tests pass.

Need help refactoring Dockerfiles across a Laravel fleet or setting up GitLab CI plus Deployer on Ubuntu? Contact us to discuss container hardening and production deployment for your stack.

Frequently Asked Questions

A multi-stage Dockerfile defines several FROM blocks in one file. Each block is a stage with its own filesystem. When a stage finishes, everything not copied forward is discarded. Only artifacts you explicitly COPY --from= an earlier stage into the final image reach production. Single-stage images permanently retain GCC, Node.js, Composer dev tools, and other compile-time dependencies. Multi-stage builds break that pattern: build fat in builder stages, ship thin in runtime. On Laravel production setups I maintain, that structural split routinely drops images from around 900 MB to under 180 MB.

Most Laravel and Node-plus-PHP stacks shrink 60–90%, often from roughly 900 MB down to 150–200 MB after removing Node, GCC, and dev Composer packages from the final image.

Name stages explicitly, such as frontend, vendor, and runtime. In the frontend stage, use Node 26 LTS to run npm ci and npm run build, then copy only public/build forward. In the vendor stage, run composer install with --no-dev, --no-interaction, --prefer-dist, and --optimize-autoloader, then copy vendor/ into runtime. Start the final stage from php:8.5-fpm-alpine, install only runtime extensions like pdo_mysql, zip, and opcache, copy vendor and compiled assets, set www-data ownership on storage and bootstrap/cache, and run php-fpm. Never install Node or Composer in the runtime stage.

Your final FROM line often matters more than clever layer ordering. A full Debian-based php:8.5-fpm image can weigh 350–450 MB before your app code arrives. php:8.5-fpm-alpine typically lands at 80–120 MB and suits most Laravel and Symfony workloads in 2026. Distroless or scratch bases can reach 20–60 MB but offer no shell and are harder to debug. Single-stage images with build tools commonly hit 800 MB to 1.2 GB and should never ship to production. For most PHP workloads, Alpine plus multi-stage copy is the practical sweet spot.

COPY --from= copies files from a named or numbered earlier stage into the current stage. It is the mechanism that makes multi-stage builds work: you select specific paths such as vendor/, public/build, or compiled binaries rather than the entire builder filesystem. Named stages like frontend and vendor make COPY --from= readable and reduce copy-paste errors during refactors. A blanket COPY --from=builder / / defeats the purpose and leaves you with a bloated production image indistinguishable from a single-stage build.

Yes. Set target: runtime in your Compose build config. Keep a separate dev Dockerfile or target that includes debugging tools; production Compose files should build only the slim runtime stage.

First builds can take longer because stages run sequentially. Cached layers usually make later builds faster, and deploy wins when registry pulls transfer far less data.

No. They reduce attack surface by removing shells and build tools from the runtime image, but they are not a complete security strategy on their own.

Multi-stage builds remove whole toolchains; layer hygiene removes waste inside each stage. Order Dockerfile instructions from least to most volatile: copy dependency manifests first, run installs, copy application source last so lock-file changes do not invalidate everything. Combine RUN instructions and purge package caches in the same layer so apt or apk cache files are not baked in forever. Use .dockerignore aggressively to exclude node_modules, vendor, .git, tests, and .env from build context. Prefer explicit COPY lists over COPY . . when layout is stable, pin base image digests, and audit layers with docker history and dive.

Exclude files that belong in builder stages only or never belong in any image: node_modules, vendor, .git, tests, storage/logs, .env, markdown files, and docker-compose files. Smaller build context means faster uploads to the Docker daemon and fewer accidental layer bloat entries. Without .dockerignore, a 400 MB node_modules folder can enter context, waste CI time, and risk stale copies landing in runtime layers. Skip README files, CI configs, and frontend source when only compiled assets from the Node stage matter at runtime.

Teams adopt the pattern and still ship 600 MB images. Copying the entire builder stage with COPY --from=builder / / is the most common failure. Running composer install without --no-dev in production stages pulls PHPUnit and PHPStan into runtime. Leaving apt, apk, or npm binaries in the final stage adds permanent bloat. Ignoring .dockerignore uploads huge folders into context. Using latest on base images breaks reproducibility. Skipping extension audits means Alpine runtime stages miss libraries behind PHP extensions like intl or redis, which surface only during smoke tests after the image is tagged.

Compare sizes with docker images before and after the switch, and log both numbers in CI so regressions show on every merge request. Inspect layer contents with dive or docker history --no-trunc. Smoke-test the runtime stage, not the builder: confirm PHP extensions load, database connections work, and queue workers start. Scan the slim image with Trivy before tagging a release candidate, because slimming exposes existing vulnerabilities rather than fixing them. Tag releases with immutable tags and promote deliberately so rollbacks stay clear when a slim image replaces a bloated one mid-incident.

Laravel apps need Node 26 LTS to compile frontend assets with npm 12 and Vite 8.x, and Composer 2.10 to install PHP packages. Neither toolchain belongs in the container running PHP-FPM behind Apache or Nginx in production. Keeping them in the final image permanently adds hundreds of megabytes and widens the attack surface with package managers and build headers. The frontend stage runs npm ci and npm run build, then copies only public/build. The vendor stage runs composer install --no-dev, then copies only vendor/. Production runs PHP 8.5 with runtime extensions, not compilers.

Alpine cuts final image size dramatically compared to Debian, but it uses musl libc instead of glibc, and some PECL extensions are harder to install or behave differently. php:8.5-fpm-alpine works well for typical Laravel API and web workloads when you explicitly apk add libraries behind extensions such as libpng-dev and libzip-dev before docker-php-ext-install. Choose Debian-based php:8.5-fpm when you need broad extension support quickly or are prototyping. Distroless bases suit minimal static builds but add operational cost for teams that rely on shell access for production debugging.

Explicit stage names make COPY --from= readable and prevent copy-paste errors during Dockerfile refactors. Instead of remembering numeric stage indexes, you reference frontend for compiled assets and vendor for the Composer tree. That clarity matters when CI builds from a clean Git checkout every time and multiple developers touch the same Dockerfile. Named stages also document intent: frontend handles asset compilation, vendor handles PHP dependencies, runtime assembles the slim production image. When onboarding a new developer or auditing a client project, named stages immediately show what ships versus what stays behind.

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: