Optimizing Docker Images for Production (Multi-stage builds)

· 3 min read

In development environments, we often use laravel/sail or full utility images for convenient debugging. But when deploying to Production, size and security are top priorities.

A bad Production Docker image usually:

  • Is huge (> 1GB) -> Slow deploy, waste bandwidth.
  • Contains unneeded source code (node_modules, tests, git folder).
  • Contains unnecessary tools (vi, nano, curl...) -> Increases attack surface.
  • Runs as root.

The solution is Multi-stage Builds.

What are Multi-stage Builds?

Simply put, you have multiple FROM instructions in one Dockerfile.

  • Stage 1: Used to Build (needs Composer, Node, NPM...).
  • Stage 2: Used to Run (only needs PHP Runtime, Nginx).

Stage 2 will copy the results from Stage 1 and discard all build tools.

Optimized Production Dockerfile for Laravel

Here is an example of a highly optimized Dockerfile:

# --- Stage 1: Build Assets (Frontend) ---
FROM node:20-alpine as frontend
WORKDIR /app
COPY package*.json vite.config.js ./
RUN npm ci
COPY resources/ ./resources/
COPY public/ ./public/
# Build assets into public/build folder
RUN npm run build 

# --- Stage 2: Install Composer Deps ---
FROM composer:2.6 as vendor
WORKDIR /app
COPY composer.json composer.lock ./
# --no-dev: Do not install require-dev (phpunit, faker...)
# --optimize-autoloader: Optimize class map
RUN composer install \
    --no-dev \
    --no-interaction \
    --prefer-dist \
    --ignore-platform-reqs \
    --optimize-autoloader \
    --no-scripts

# --- Stage 3: Final Production Image ---
FROM php:8.3-fpm-alpine

# Install necessary extensions for Laravel
# Use install-php-extensions (utility script) for ease
ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN chmod +x /usr/local/bin/install-php-extensions && \
    install-php-extensions pdo_mysql bcmath opcache redis intl zip exif

# PHP Config for Prod
COPY ./docker/php/local.ini /usr/local/etc/php/conf.d/local.ini
COPY ./docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini

WORKDIR /var/www

# Copy vendor from Stage 2
COPY --from=vendor /app/vendor/ ./vendor/

# Copy assets from Stage 1
COPY --from=frontend /app/public/build/ ./public/build/

# Copy application source code (except those in .dockerignore)
COPY . .

# Setup permissions (important: do not run as root)
RUN chown -R www-data:www-data /var/www \
    && chmod -R 755 /var/www/storage

# Switch user
USER www-data

# Clean up
# (On Alpine, apk cache clears itself or add rm -rf /var/cache/apk/*)

EXPOSE 9000
CMD ["php-fpm"]

Key Optimizations

  1. Base Image Alpine: php:8.3-fpm-alpine is only around 50MB compared to hundreds of MB for Debian/Ubuntu versions.
  2. Separate JS Build Stage: node_modules is heavy and contains thousands of files. We only copy the final public/build folder.
  3. Separate PHP Vendor Stage: We run composer install --no-dev. This removes PHPUnit, Mockery... from production image.
  4. .dockerignore: Crucial. Must ignore .git, tests, node_modules (if local), storage/*.key...
  5. OPCache: Must be enabled and configured to pre-load PHP code into RAM for peak performance.

Combining with Nginx

Usually, you will have an additional Nginx container (sidecar) or build Nginx into the same image (if using Supervisor). Standard Kubernetes/ECS models usually separate them:

  • 1 Container running PHP-FPM (logic processing).
  • 1 Container running Nginx (serving static files and proxy pass to PHP-FPM).

With the Dockerfile above, your final image might be just around 100MB - 150MB (including framework code), compared to 800MB+ if done naively.

Conclusion

A compact Docker Image not only saves storage costs (ECR, Docker Hub) but also enables faster Autoscaling (reduced pull time) and better security (fewer unneeded components). Investing once in a standard Dockerfile is a long-term profitable investment for the project.

Comments