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:
-
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. -
Deploy is slow and bandwidth intensive: Each CI/CD process must download and extract gigabyte-sized images to a production server or Kubernetes worker.
-
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
~/.npmcaches are left entirely in thefrontend-buildercontainer. All we bring to the final image is the static folder/public/build. -
The Composer tool and development packages (
fakerphp,mockery) are left in thevendor-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
.envfile does not leak into image: Make sure.envlisted 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-datacommand remains active at the end of the Dockerfile. Running a web server process asrootin production is a fatal security risk. -
Storage directory permissions: Directories
storageandbootstrap/cachemust remain writable by the userwww-datato store sessions, logs, and framework file caches. -
Scanning vulnerability: Before deploying, scan your image using a modern container scanner like Trivy:
Bashtrivy 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
Software Engineer & Tech Educator
Software Engineer and Tech Educator sharing insights on web development and software architecture.