Optimising Laravel Docker images with multi-stage builds

6 min read

The first Dockerfile I ever wrote for a Laravel app installed Composer, Node, npm, and every dev dependency into a single image. It worked. It was also 870 MB and took four minutes to push to a registry. Multi-stage builds solve this: you use throw-away builder stages to compile assets and install dependencies, then copy only the final artefacts into a lean runtime image. The result is a container with no build tooling, no dev packages, and no dead weight.

Why Laravel Docker images balloon

A naive single-stage Dockerfile ends up carrying:

  • The full Composer binary and all dev dependencies (phpunit, larastan, pint, etc.)
  • Node.js and npm, long after your Vite assets have been built
  • Build headers and intermediate compilation artefacts from PHP extensions
  • Your local vendor/ directory if you forget .dockerignore

Each RUN instruction adds a layer. Even if you delete something in a later step, the original bytes are still in the image history. Multi-stage builds avoid this entirely — builder stages are never included in the final image.

The laravel docker multi-stage build structure

Three stages covers every Laravel app I've worked on:

  1. Composer stage — install production-only PHP dependencies
  2. Node stage — compile Vite/npm assets
  3. Runtime stage — the final PHP-FPM image, pulling artefacts from stages 1 and 2
# ─── Stage 1: Composer production dependencies ─────────────────────────────
FROM composer:2 AS composer-deps

WORKDIR /app

# Copy lockfiles first — Docker cache busts only when these change
COPY composer.json composer.lock ./

RUN composer install \
    --no-dev \
    --no-interaction \
    --no-progress \
    --optimize-autoloader \
    --prefer-dist

# ─── Stage 2: Node / Vite assets ────────────────────────────────────────────
FROM node:22-alpine AS node-assets

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts

COPY resources/ resources/
COPY vite.config.js ./
COPY public/ public/

RUN npm run build

# ─── Stage 3: Production runtime ────────────────────────────────────────────
FROM php:8.4-fpm-alpine AS runtime

# Laravel needs pdo_mysql for the database, opcache for performance,
# and pcntl for queue signal handling
RUN docker-php-ext-install \
        pdo_mysql \
        opcache \
        pcntl

# OPcache: validate_timestamps=0 means no file-stat checks at runtime.
# Safe in containers because the image is immutable — files never change.
RUN { \
        echo 'opcache.enable=1'; \
        echo 'opcache.memory_consumption=256'; \
        echo 'opcache.max_accelerated_files=20000'; \
        echo 'opcache.validate_timestamps=0'; \
    } >> /usr/local/etc/php/conf.d/opcache.ini

WORKDIR /var/www/html

# Copy application source (node_modules and vendor are .dockerignored)
COPY . .

# Overwrite vendor/ with the production-only dependencies from stage 1
COPY --from=composer-deps /app/vendor ./vendor

# Bring in compiled JS/CSS from the Vite build in stage 2
COPY --from=node-assets /app/public/build ./public/build

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

EXPOSE 9000
CMD ["php-fpm"]

The --optimize-autoloader flag in the Composer stage generates a class map, which means PHP can resolve classes without scanning the filesystem at runtime — a meaningful latency win in production.

The .dockerignore you need

Without .dockerignore, the Docker build context sends your entire project directory to the daemon on every build — including node_modules/ (often hundreds of megabytes) and any local .env file. Keep this alongside your Dockerfile:

# .dockerignore
vendor/
node_modules/
public/build/
.git/
.env
.env.*
.env.example
storage/logs/*
storage/framework/cache/*
tests/
docker-compose*.yml
*.md

The vendor/ and public/build/ directories are excluded because the multi-stage build provides them from stages 1 and 2. Copying them from the host would just get overwritten anyway — but excluding them keeps the build context small and the build fast.

Choosing a base image

php:8.4-fpm-alpine is what I use for most projects. It weighs in around 30 MB compressed and gives you a clean PHP-FPM setup you control entirely.

If you want more batteries included, serversideup/php is worth a look. Their images add an unprivileged user by default, pre-bake common extensions, and include Laravel-specific automations for running migrations and linking storage on startup. The trade-off is a larger image and less control over exactly what's installed.

FrankenPHP (dunglas/frankenphp) is an emerging alternative that embeds a Go-powered web server inside the PHP runtime, so you don't need a separate Nginx container. It pairs naturally with Laravel Octane for worker-mode deployments. I'd treat it as production-ready for new projects now, though I'd give it more weight if you're already on Octane. Note: FrankenPHP can have issues on ARM64 in some configurations — pin platform: linux/amd64 in your compose file if you're targeting x86 production from an Apple Silicon machine.

Gotchas and edge cases

OPcache and validate_timestamps. Setting opcache.validate_timestamps=0 is correct in a container — the filesystem is immutable once the image is built. If you mount your source directory in development with a bind mount and use this setting, OPcache will serve stale code after you edit a file. Use a separate docker-compose.yml override for local dev that either removes the OPcache ini or sets validate_timestamps=1.

The COPY . . ordering matters. The COPY --from=composer-deps and COPY --from=node-assets instructions come after COPY . .. This means the production vendor directory from stage 1 overwrites anything that might have been in the source tree. If you forget .dockerignore and accidentally include a local vendor/ directory in the build context, the stage-1 copy still wins because it runs last.

BuildKit is on by default. Docker 23 and later — which includes all current Docker Desktop installs — have BuildKit enabled by default. You don't need DOCKER_BUILDKIT=1 anymore. BuildKit is what enables proper layer caching for multi-stage builds, including the --cache-from and --cache-to flags used in the GitHub Actions workflow below.

Debugging individual stages. If a build stage fails, you can target it directly:

docker build --target composer-deps -t debug-composer .
docker run --rm -it debug-composer sh

This drops you into the composer stage so you can inspect what was installed without building the full image.

Running migrations. Don't add an entrypoint that runs php artisan migrate — it will run on every container start, including during rolling deployments when multiple replicas are spinning up. Instead, run migrations as a separate pre-deployment job in your CI pipeline, or as a Kubernetes init container. Your zero-downtime deployment workflow is the right place for this.

GitHub Actions: build and push to GHCR

This workflow builds on every push to main, tags the image with the commit SHA, and uses GitHub's layer cache to skip unchanged stages:

# .github/workflows/docker-build.yml
name: Build and push Docker image

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          # Persist build cache between runs — skips unchanged stages
          cache-from: type=gha
          cache-to: type=gha,mode=max

The cache-from: type=gha line is where multi-stage builds really earn their keep in CI. The Composer and Node stages only rebuild when composer.lock or package-lock.json change. On a typical code-only push, both builder stages are cache hits and only the runtime stage reruns.

Image size results

Here are real numbers from a mid-size Laravel app — API-only, no Livewire:

Approach Compressed size
Single-stage (php:8.4-fpm, Debian) ~820 MB
Single-stage (php:8.4-fpm-alpine) ~310 MB
Multi-stage (php:8.4-fpm-alpine) ~118 MB

The jump from single Alpine to multi-stage Alpine comes almost entirely from removing Composer, npm, Node.js, and all dev dependencies from the final layer.

Wrapping up

Add the Dockerfile and .dockerignore to your repo, plug the GitHub Actions workflow in, and your first multi-stage build will show you the real numbers. The pattern scales: if you add Redis extension or gd support later, add them to the docker-php-ext-install line in stage 3 and nothing else changes. If you're running Laravel Octane, the FrankenPHP base image is worth revisiting — but that deserves its own article.

docker
laravel
php
devops
github-actions
Steven Richardson

Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.