Backend••8 min read•27 views

Docker Multi-Stage Build for Laravel Production: A Guide to Building Super Sleek and Secure Images

With this technique, we separate the compilation process (compile assets and vendor installation) from the final runtime. The end result?

In the previous article, we successfully assembled a local Laravel stack using Docker Compose. Everything runs smoothly on the local machine: code mounts via volume, PHP extensions are complete, databases are connected, and the live reload feature runs without a hitch.

But, as soon as we talk about production, the situation changes 180 degrees.

If you push yesterday's development image directly to the production registry (such as Docker Hub, AWS ECR, or GitHub Packages), you will face three big problems:

  1. Image size swells drastically (bloated): Often exceeds 1 GB to 1.5 GB because it carries the C++ compiler, development extensions, Composer dev-dependencies (PHPUnit, Faker), up to the node_modules.
  2. Deploy is slow and bandwidth intensive: Each CI/CD process must download and extract gigabyte-sized images to a production server or Kubernetes worker.
  3. Attack surface widens: Allow utilities such as npm, git, build tools, and non-compiled source code reside in production containers providing an extra hole for attackers when the system is exploited.
The industry standard solution to this problem is Docker Multi-Stage Build.

With this technique, we separate the compilation process (compile assets and vendor installation) from the final runtime. The end result? Laravel production images are slim (can be under 120 MB), clean of build trash, and much more secure.

Understanding Multi-Stage Build Concepts

Imagine you are building a modular house.

You will need a chainsaw, cement, mixer truck, and welder during the construction process. But once the house is finished and ready to be lived in, do all those molen machines and piles of leftover cement need to be put in the living room? Obviously not. You only need finished furniture and a clean building.

In conventional Docker without multi-stage, all those "builder tools" are locked into the final image.

With Multi-Stage Build, a single Dockerfile file can have multiple FROM instructions. Each FROM instruction represents a separate stage:

[ Stage 1: Frontend Builder (Node.js) ]
    Compile Tailwind/Vite ──► Extract: /public/build

[ Stage 2: Composer Builder (PHP CLI) ]
    composer install --no-dev ──► Extract: /vendor

                     │ (Only copy the final result)
                     ▼
[ Stage 3: Production Runtime (PHP-FPM Minimum) ]
    Contains only: PHP Runtime + Extensions + Source Code + /public/build + /vendor
    (No Node.js, no OS compiler, no git)
Container production You don't need to know what npm is or how Vite works. It only needs the output files .css and .js which have been minified.

Designing Dockerfile.prod Multi-Stage

Let's create a Dockerfile.prod that divides the workflow into 3 isolated stages. Create this file in the docker/php/Dockerfile.prod folder or directly in the root of your project.

Dockerfile
# =============================================================================
# STAGE 1: FRONTEND BUILDER (Node.js Stage)
# =============================================================================
# This stage specifically compiles Vite, Tailwind CSS, or JavaScript assets.
FROM node:20-alpine AS frontend-builder

WORKDIR /app

# Copy package definitions first to maximize Docker layer caching
COPY package.json package-lock.json ./

# Cleanly install frontend dependencies
RUN npm ci

# Copy the source code needed for the frontend build process
COPY resources/ ./resources/
COPY vite.config.js tailwind.config.js* postcss.config.js* ./
COPY public/ ./public/

# Run the asset compilation process to public/build
RUNnpm run build


# =============================================================================
# STAGE 2: BACKEND DEPENDENCIES BUILDER (Composer Stage)
# =============================================================================# This stage specifically downloads PHP vendor packages without dev-dependencies.
FROM composer:2 AS vendor-builder

WORKDIR /app

# Copy the Composer manifesto file
COPY composer.json composer.lock ./

# Download the PHP package with production optimization flags:
# - --no-dev: skip testing packages (PHPUnit, Mockery, Faker, etc)
# - --no-scripts: prevent artisan scripts from running before the source code is complete
# - --no-autoloader: delay autoloader creation until all source code exists
# - --ignore-platform-reqs: avoid build failure if system extensions are not installed at this stage
RUN composer install \
    --no-dev\
    --no-interaction\
    --no-plugins\
    --no-scripts\
    --prefer-dist\
    --optimize-autoloader


# =============================================================================
# STAGE 3: PRODUCTION RUNTIME (PHP-FPM Minimalist)
# =============================================================================
# Final image that will actually run on the production server.
FROM php:8.3-fpm-alpine AS production

# Metadata label for image tracking
LABEL maintainer="Engineering Team"
LABEL environment="production"

# Install the minimum possible OS level system extensions that are absolutely necessary for the runtime
# Using Alpine package manager (apk) to make the image size very small
RUN apk add --no-cache \
    libpng \
    libjpeg-turbo \
    freetype\
    libzip \
    oniguruma\
    icu-libs

# Install temporary build dependencies for compiling PHP modules, then immediately delete the build-deps
RUN apk add --no-cache --virtual .build-deps \
    $PHPIZE_DEPS \
    libpng-dev\
    libjpeg-turbo-dev\
    freetype-dev\
    libzip-dev\
    oniguruma-dev\
    icu-dev\
    && docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) \
        pdo_mysql \
        mbstring \zip\
        bcmath\
        intl\
        opcache\
        gd\
    && apk del .build-deps

# Configure PHP Production & Opcache
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

# Apply aggressive Opcache tuning for production performance
RUN { \
    echo 'opcache.memory_consumption=128'; \
    echo 'opcache.interned_strings_buffer=8'; \
    echo 'opcache.max_accelerated_files=10000'; \
    echo 'opcache.revalidate_freq=0'; \
    echo 'opcache.validate_timestamps=0'; \
    echo 'opcache.enable_cli=1'; \
    echo 'opcache.fast_shutdown=1'; \
}> /usr/local/etc/php/conf.d/opcache-recommended.ini

# Set working directory
WORKDIR /var/www/html

# 1. Copy the entire Laravel application source code
COPY . /var/www/html

# 2. Copy PHP vendor from STAGE 2 (Composer Stage)
COPY --from=vendor-builder /app/vendor /var/www/html/vendor

# 3. Copy frontend artifacts from STAGE 1 (Node Stage)
COPY --from=frontend-builder /app/public/build /var/www/html/public/build

# 4. Take the Composer binary for a moment to generate the final production classmap
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN composer dump-autoload --optimize --classmap-authoritative --no-dev \
    && rm /usr/bin/composer

# Setup permissions: hand over file ownership to the standard user www-data
RUN chown -R www-data:www-data /var/www/html \
    && chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache# Run the container using a non-root user for security
USER www-data

EXPOSE 9000

CMD ["php-fpm"]

Anatomical Surgery and Optimization Secrets

Each of the lines above was designed for specific architectural reasons. Let's dissect the important details:

1. Debian vs Alpine Drastic Differences

In the local environment in the previous article, we used the Debian base image (php:8.3-fpm). Debian is very beginner-friendly because its debugging tools are complete.

But for production, we switch to Alpine Linux (php:8.3-fpm-alpine). Alpine's base image is less than 10 MB, compared to Debian's 100+ MB for just the base operating system.

2. Virtual Build Dependencies Trick (apk add --virtual)

Notice this block in the final stage:

Dockerfile
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS ... \
    && docker-php-ext-install ...\
    && apk del .build-deps
To compile C extensions (such as GD or intl), PHP needs a compiler (gcc, make, autoconf). But the compiler is deprecated once the extension has finished compiling.

By tagging the packages into the virtual group .build-deps and then deleting them (apk del) in one same RUN instruction, the compiler cache will not remain in any Docker layer.

3. Extract Results, Leave the Machine (COPY --from=...)

This is the heart of the multi-stage build:

  • Node.js, npm packages, and ~/.npm caches are left entirely in the frontend-builder container. All we bring to the final image is the static folder /public/build.
  • The Composer tool and development packages (fakerphp, mockery) are left in the vendor-builder.

4. Opcache Lock (validate_timestamps=0)

On a production server, the application code file will not change while the container is running (because there is no volume mounting from the laptop).

By setting opcache.validate_timestamps=0, PHP will not waste CPU I/O cycles checking to see if any .php files were edited. Code is directly read 100% from shared memory, making routing and controller rendering execution much faster.

How to Build and Test Image Production

To test whether Dockerfile.prod runs properly, run the build command in your local terminal:

Bash
# Build image production
docker build -t laravel-app:prod -f Dockerfile.prod .
After the process is complete, let's prove its efficiency. Compare the size of the development image with the production image you just created:

Bash
docker images | grep laravel-app
The output will usually look like this:

Plaintext
REPOSITORY TAG IMAGE ID CREATED SIZE
laravel-app dev a1b2c3d4e5f6 2 days ago 1.15GB
laravel-app prod f6e5d4c3b2a1 10 seconds ago 118MB
Image was successfully cropped to almost 90% smaller without sacrificing application functionality at all.

Startup Execution in Production (Entrypoint Script)

In production, the app container needs to perform some initialization tasks when it first boots before accepting requests (such as configuration caching and database migration).

Create a file named docker/entrypoint.prod.sh:

Bash
#!/bin/sh
set -e

# Cache configuration, routes, and blade views for instant framework boot
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

# Run automatic database migration (optional, use with caution if multi-pod cluster)
# php artisan migrate --force

# Execute main command (php-fpm)
exec "$@"
Give execution permission to the script file on your host:

Bash
chmod +x docker/entrypoint.prod.sh
Then at the end of Dockerfile.prod, add the script before CMD:

Dockerfile
COPY docker/entrypoint.prod.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

ENTRYPOINT ["entrypoint.sh"]
CMD ["php-fpm"]

Security Checklist Before Deploying to Registry

Before this image is pushed to Amazon ECR, Google Artifact Registry, or DigitalOcean Container Registry, make sure this checklist passes:

  • The .env file does not leak into image: Make sure .env listed in .dockerignore. Production credential values must be injected at runtime via container environment variables or secret manager (Kubernetes Secrets / AWS Secrets Manager), not hardcoded during build.
  • Active non-root users: Make sure the USER www-data command remains active at the end of the Dockerfile. Running a web server process as root in production is a fatal security risk.
  • Storage directory permissions: Directories storage and bootstrap/cache must remain writable by the user www-data to store sessions, logs, and framework file caches.
  • Scanning vulnerability: Before deploying, scan your image using a modern container scanner like Trivy:

    Bash
    trivy image laravel-app:prod
    
Adopting multi-stage build is not just a trick to save server hard disk space. This pattern is the foundation of a cloud-native architecture that differentiates amateur applications from enterprise systems that are ready to scale horizontally, are safe from exploiting build dependencies, and have a deployment cycle of seconds.
 
Sigit Wasis Subekti

Sigit Wasis Subekti

Software Engineer & Tech Educator

Software Engineer and Tech Educator sharing insights on web development and software architecture.