log in
consulting hosting industries the daily tools about contact

Shaving 600MB off a Laravel Image with Multi-Stage Builds

Multi-stage Docker builds aren't magic — they're a scalpel. Here's what they actually copy, what they silently skip, and how I got a Laravel image from 980MB to 340MB.

I spent an embarrassing amount of time thinking multi-stage Docker builds were basically magic — build your stuff in one container, copy it to another, done, small image. Then I deployed a Laravel app to a client's VPS and watched the image clock in at 980MB. That's when I actually sat down and read what COPY --from does and, more importantly, what it doesn't do.

The short version: it copies files. Just files. Not environment variables from the build stage, not the package manager state, not anything you ran. If you want something in the final image, you have to explicitly put it there. This sounds obvious in retrospect, but the way most tutorials are written makes it easy to assume the final stage inherits more than it does.

What the Problem Actually Is

A typical Laravel app has two dependency trees that you do not want in production:

  1. Composer dev dependencies — PHPUnit, Mockery, Laravel Pint, Collision, Ignition, all the testing and debug tooling. These can easily add 50-100MB of PHP files you have zero use for in prod.
  2. Node/npm — the entire node_modules tree you pulled in to run Vite and compile your assets. On a modern Laravel app with Tailwind, Alpine, and a few plugins, node_modules alone can be 400-500MB.

Without multi-stage builds, both of those live in your image because you ran composer install and npm install in the same layer chain that becomes your final artifact. With multi-stage builds done correctly, neither of them make it to the final image — only the compiled CSS/JS output and the production PHP dependencies.

That's where the 600MB came from. And the fix wasn't complicated once I understood the copy semantics.

A Dockerfile That Actually Works

Here's the Dockerfile I've landed on for Laravel apps at NWOS. I'll walk through the stages after.

# ── Stage 1: PHP/Composer dependencies ──────────────────────────────────────
FROM composer:2.7 AS composer-deps

WORKDIR /app

COPY composer.json composer.lock ./

# Install production deps only — no dev packages, no scripts yet
RUN composer install \
    --no-dev \
    --no-scripts \
    --no-interaction \
    --prefer-dist \
    --optimize-autoloader

# Copy the full source so post-install scripts (if any) have context
COPY . .

# Run post-install scripts now that full source is present
RUN composer run-script post-autoload-dump 2>/dev/null || true


# ── Stage 2: Node/Vite asset build ──────────────────────────────────────────
FROM node:20-alpine AS node-build

WORKDIR /app

COPY package.json package-lock.json vite.config.js ./
# Copy any other config files Vite needs at build time
COPY tailwind.config.js postcss.config.js ./

RUN npm ci --prefer-offline

# Need source files for Vite to compile
COPY resources/ resources/
COPY public/ public/

RUN npm run build


# ── Stage 3: Final production image ─────────────────────────────────────────
FROM php:8.3-fpm-alpine AS production

# System deps your app actually needs at runtime
RUN apk add --no-cache \
    nginx \
    supervisor \
    libpng-dev \
    libzip-dev \
    oniguruma-dev \
    && docker-php-ext-install \
        pdo_mysql \
        gd \
        zip \
        bcmath \
        opcache

WORKDIR /var/www/html

# PHP source — no dev dependencies, no node_modules
COPY --from=composer-deps /app /var/www/html

# Compiled assets only — not node_modules
COPY --from=node-build /app/public/build /var/www/html/public/build

# Config files
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/php.ini /usr/local/etc/php/conf.d/app.ini

RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache

EXPOSE 80

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

The key line is this one:

COPY --from=node-build /app/public/build /var/www/html/public/build

Not /app (the whole working directory). Not /app/node_modules. Just the compiled output directory. The 450MB node_modules tree never touches the final image because you never told Docker to copy it. This is the thing that wasn't clicking for me — by default nothing transfers between stages. You get exactly what you COPY --from.

The Gotchas That Will Bite You

--no-scripts then re-running scripts manually. I split this in the Dockerfile above and it matters. If you COPY composer.json composer.lock first and install, then COPY . ., the post-install scripts run before your app source exists. But if you skip --no-scripts entirely, you miss post-autoload-dump, which Laravel uses to register package service providers. I've shipped broken containers this way. The two-step approach (install with --no-scripts, copy source, then run the dump script explicitly) solves it.

The composer cache lives in /root/.composer — not in your app. If you're not mounting a cache volume in CI, you're re-downloading the entire package registry on every build. Add --mount=type=cache,target=/root/.composer to the RUN composer install line if your CI environment supports BuildKit (it should, it's been default since Docker 23).

RUN --mount=type=cache,target=/root/.composer \
    composer install --no-dev --no-scripts --no-interaction --prefer-dist --optimize-autoloader

Same for npm:

RUN --mount=type=cache,target=/root/.npm \
    npm ci --prefer-offline

This cut our CI build times nearly in half on a project with heavy Composer deps.

Alpine vs. Debian PHP extensions. I use php:8.3-fpm-alpine because the image is smaller, but the extension install process is different from Debian-based images. gd on Alpine needs libpng-dev, freetype-dev, and libjpeg-turbo-dev installed via apk before you run docker-php-ext-configure gd. I've wasted an hour debugging a container that had a broken imagepng() because I missed one of those apk packages. If you're new to Alpine PHP images, the Debian variants are more forgiving and the size difference is less dramatic than you'd think (~30MB).

Environment variables from build stages don't carry over. This one gets people. If you set ENV APP_ENV=production in your composer stage, that variable is gone in your final stage unless you set it again. Same with ARG. Every stage starts fresh. Intentional behavior, but it trips you up the first time.

.dockerignore is load-bearing. If you don't have a solid .dockerignore, your COPY . . in the composer stage is pulling in your local .env, node_modules, vendor, and whatever else is sitting in your project root. My standard .dockerignore for Laravel:

.env
.env.*
node_modules/
vendor/
.git/
.github/
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
*.log
docker-compose*.yml

Without this, your build context bloats and COPY . . does things you didn't intend.

When I'd Reach for This

Every Laravel app that gets containerized. Full stop. The cost is a slightly more complex Dockerfile, and the payoff is consistent: smaller images, faster pulls, less attack surface in production. For NWOS clients on managed hosting, smaller images mean faster deploys and lower egress costs on the registry side. For clients on fixed-size VPSes, it means I'm not fighting disk space six months in.

I'd be more conservative if the app is staying in a local Docker Compose setup that never gets pushed anywhere — in that case the build complexity isn't buying you much, and the cache behavior of a single-stage build is actually simpler to reason about during development.

I also keep a separate Dockerfile.dev that's a single-stage build with dev dependencies included, Xdebug installed, and no asset pre-compilation. Multi-stage builds are a production concern. Mixing them into your dev workflow because you want a single Dockerfile is a trap that makes local development slower and more annoying than it needs to be.

Closing

The mental model shift that fixed everything for me: each stage in a multi-stage build is a completely independent container that happens to have a name you can reference. COPY --from is just scp between containers. Nothing else moves. Once I stopped thinking of stages as "inherited" and started thinking of them as isolated boxes I'm selectively extracting files from, the whole thing clicked — and I stopped accidentally shipping 450MB of JavaScript tooling into production PHP containers.

Need help shipping something like this? Get in touch.