Optimising Laravel Docker images with multi-stage builds
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:
- Composer stage — install production-only PHP dependencies
- Node stage — compile Vite/npm assets
- 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.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.