Beyond the Basics: Advanced Docker Multi-Stage Builds for Optimized Laravel Deployments and CI/CD Pipelines
Optimizing Laravel Docker Images with Multi-Stage Builds
For modern PHP applications, particularly those built with frameworks like Laravel, Docker has become an indispensable tool for consistent development, testing, and deployment. However, a naive Dockerfile can lead to bloated images containing development dependencies, build tools, and unnecessary artifacts, increasing build times, image sizes, and potential security attack surfaces. Multi-stage builds offer a robust solution to this problem by allowing us to use multiple `FROM` instructions in a single Dockerfile, discarding intermediate build artifacts and keeping only the essential runtime components in the final image.
This approach is particularly effective for Laravel applications where we need to compile assets (JavaScript, CSS) using tools like npm/yarn, run PHP dependency installations with Composer, and then package only the compiled assets and production-ready PHP code. Let’s explore a practical, multi-stage Dockerfile for a typical Laravel deployment.
Stage 1: The Build Environment (Composer & Node.js)
Our first stage will focus on installing PHP dependencies via Composer and Node.js dependencies via npm or yarn, and crucially, compiling our front-end assets. We’ll use official PHP and Node.js images as our base, ensuring a clean and well-maintained starting point.
# Stage 1: Build Environment
FROM php:8.2-fpm AS builder
# Set working directory
WORKDIR /var/www/html
# Install system dependencies for Composer and Node.js
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libssl-dev \
libonig-dev \
libxml2-dev \
zip \
curl \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd \
&& docker-php-ext-install pdo pdo_mysql zip exif pcntl opcache \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
# Install Node.js and npm/yarn (using a specific version for consistency)
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g yarn
# Copy application files
COPY . .
# Install Composer dependencies
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Install Node.js dependencies and compile assets
RUN yarn install --frozen-lockfile
RUN yarn production
In this `builder` stage:
- We start with a PHP 8.2 FPM image, suitable for production environments.
- Essential system packages for PHP extensions, Composer, and Node.js are installed.
- Composer is copied from its official image to ensure we’re using a consistent, up-to-date version.
- Node.js and Yarn are installed. Using `setup_18.x` and `yarn install –frozen-lockfile` promotes reproducible builds.
- Application files are copied into the container.
- Composer dependencies are installed with production flags (`–no-dev`, `–optimize-autoloader`).
- Node.js dependencies are installed, and front-end assets are compiled for production using `yarn production`.
Stage 2: The Production Environment (Nginx & PHP-FPM)
The second stage will create our lean production image. It will copy only the necessary artifacts from the `builder` stage: the installed Composer dependencies, the compiled assets, and the application code. We’ll also set up Nginx to serve the application.
# Stage 2: Production Environment
FROM php:8.2-fpm-alpine AS production
# Install Nginx and other runtime dependencies
RUN apk update && apk add --no-cache \
nginx \
supervisor \
oniguruma-dev \
libzip-dev \
&& docker-php-ext-install pdo pdo_mysql zip exif pcntl opcache \
&& apk del oniguruma-dev libzip-dev \
&& rm -rf /var/cache/apk/*
# Set working directory
WORKDIR /var/www/html
# Copy application code and dependencies from the builder stage
COPY --from=builder /var/www/html/vendor /var/www/html/vendor
COPY --from=builder /var/www/html/public /var/www/html/public
COPY --from=builder /var/www/html/bootstrap/cache /var/www/html/bootstrap/cache
COPY --from=builder /var/www/html/storage /var/www/html/storage
COPY --from=builder /var/www/html/routes /var/www/html/routes
COPY --from=builder /var/www/html/app /var/www/html/app
COPY --from=builder /var/www/html/config /var/www/html/config
COPY --from=builder /var/www/html/resources /var/www/html/resources
# Copy Nginx configuration
COPY docker/nginx.conf /etc/nginx/sites-available/default
RUN ln -s /etc/nginx/sites-available/default /etc/nginx/sites-enabled/
# Copy Supervisor configuration for PHP-FPM and Nginx
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Create storage symlink for Nginx
RUN ln -sfn /var/www/html/storage/app/public /var/www/html/public/storage
# Expose port 80
EXPOSE 80
# Start Supervisor to manage Nginx and PHP-FPM
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
In the `production` stage:
- We use a lean `php:8.2-fpm-alpine` image for a smaller footprint.
- Nginx and Supervisor are installed. Supervisor is crucial for managing multiple processes (Nginx and PHP-FPM) in a container.
- PHP extensions required at runtime are installed.
- Crucially, only the necessary directories (`vendor`, `public`, `bootstrap/cache`, `storage`, `routes`, `app`, `config`, `resources`) are copied from the `builder` stage. This excludes development dependencies, Node.js source files, and intermediate build artifacts.
- Nginx configuration is copied and enabled.
- Supervisor configuration is copied to manage Nginx and PHP-FPM.
- A symbolic link is created for the public storage directory, essential for serving assets like images.
- Port 80 is exposed.
- The `CMD` uses `supervisord` to start and manage both Nginx and PHP-FPM processes.
Supporting Configuration Files
The Dockerfile relies on a few external configuration files. Here are examples for `docker/nginx.conf` and `docker/supervisord.conf`.
docker/nginx.conf
server {
listen 80;
server_name localhost;
root /var/www/html/public;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass php-fpm:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
location ~ /\.ht {
deny all;
}
location /storage {
alias /var/www/html/storage/app/public;
expires 30d;
add_header Cache-Control "public";
}
}
docker/supervisord.conf
[supervisord] nodaemon=true user=root [program:nginx] command=/usr/sbin/nginx -g "daemon off;" autostart=true autorestart=true priority=10 stdout_logfile=/var/log/supervisor/nginx_stdout.log stderr_logfile=/var/log/supervisor/nginx_stderr.log [program:php-fpm] command=/usr/local/sbin/php-fpm --nodaemonize --fpm-config /usr/local/etc/php-fpm.conf autostart=true autorestart=true priority=20 stdout_logfile=/var/log/supervisor/php-fpm_stdout.log stderr_logfile=/var/log/supervisor/php-fpm_stderr.log
Note that in the Nginx configuration, `fastcgi_pass php-fpm:9000;` assumes you are using Docker Compose and have a service named `php-fpm` that exposes port 9000. If running a single container, you would typically use `unix:/var/run/php/php8.2-fpm.sock` or `127.0.0.1:9000` depending on your PHP-FPM setup.
Integrating into CI/CD Pipelines
These multi-stage builds are exceptionally well-suited for CI/CD pipelines. The build stage can be used to run tests, static analysis, and other checks without needing to install all production dependencies in the CI runner itself. The final production image is then lean and ready for deployment.
A typical CI pipeline step might look like this:
# Example CI pipeline step (e.g., GitLab CI, GitHub Actions) # Build the builder stage for testing docker build --target builder -t my-laravel-app:build . # Run tests against the builder stage docker run --rm my-laravel-app:build composer test # or docker run --rm my-laravel-app:build yarn test # If tests pass, build the final production image docker build -t my-laravel-app:latest . # Push the production image to a registry docker push my-laravel-app:latest
This strategy significantly reduces the time and resources required for your CI jobs. Tests are executed in an environment that closely mirrors production, and the final artifact is a highly optimized Docker image.
Further Optimizations and Considerations
- Caching: Leverage Docker’s build cache effectively. Place commands that change frequently (like copying application code) later in the Dockerfile, and commands that change less often (like installing dependencies) earlier.
- Security: Regularly update base images and dependencies. Use specific versions for reproducibility. Avoid running as root where possible (though for simplicity in this example, we use root for `supervisord`).
- Environment Variables: Manage environment-specific configurations using Docker’s `–env` flag or Docker Compose’s `.env` files, rather than baking them into the image.
- Health Checks: Implement Docker health checks to ensure your application is truly ready after deployment.
- Multi-Container Applications: For more complex setups, use Docker Compose to define your Nginx, PHP-FPM, and database services, linking them together. The `php-fpm:9000` in the Nginx config assumes this setup.
By adopting multi-stage builds, you can achieve significantly smaller, more secure, and faster-deploying Docker images for your Laravel applications, leading to more efficient development workflows and robust production environments.