Chapter 3: The Dockerfile — Multi-Stage Build
3.1 Why Multi-Stage Builds?
A naive Dockerfile installs everything (Composer, Node.js, npm, dev dependencies) in a single layer. The resulting image is hundreds of megabytes larger than it needs to be and may contain security vulnerabilities from build tools that have no business being in production.
Multi-stage builds solve this by separating the build environment from the runtime environment:
• Stage 1 (builder): Has Composer, Node.js, npm, and all dev tooling installed. Installs production PHP dependencies and compiles JavaScript assets.
• Stage 2 (production): Starts from a clean Alpine Linux base. Copies only the compiled output from Stage 1. Contains nothing that is not needed at runtime.
Metric Single-Stage vs Multi-Stage
Image size ~800MB vs ~120MB
Attack surface High (build tools, dev deps) vs Low (runtime only)
Build caching Poor vs Excellent (stages cache independently)
Security scanning Many CVEs from build tools vs Minimal
3.2 The Complete Dockerfile
docker/Dockerfile
# ─────────────────────────────────────────────────────────────────────────
# Stage 1: builder
# Installs all dependencies and compiles assets.
# This stage is NEVER shipped to production.
# ─────────────────────────────────────────────────────────────────────────
FROM php:8.3-cli AS builder
# Install system dependencies needed during the build phase:
# git - Composer needs this to clone packages from GitHub
# zip/unzip - needed for PHP archive operations
# libpng-dev, libonig-dev, libxml2-dev - headers for PHP extensions
# nodejs, npm - for compiling JS assets with Vite
RUN apt-get update && apt-get install -y \
git curl zip unzip \
libpng-dev libonig-dev libxml2-dev \
nodejs npm \
&& docker-php-ext-install pdo pdo_sqlite mbstring exif pcntl bcmath gd \
&& curl -sS https://getcomposer.org/installer | php \
&& mv composer.phar /usr/local/bin/composer \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy composer files first to leverage Docker layer caching.
# If composer.json and composer.lock have not changed, Docker reuses
# the cached layer even if application code has changed.
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--optimize-autoloader \
--no-interaction \
--no-scripts
# Copy package.json for the same caching benefit
COPY package.json package-lock.json ./
RUN npm ci
# Now copy the rest of the application code
COPY . .
# Run Composer scripts now that the full codebase is present
RUN composer run-script post-autoload-dump --no-interaction 2>/dev/null || true
# Compile JavaScript/CSS assets
RUN npm run build
# ─────────────────────────────────────────────────────────────────────────
# Stage 2: production
# Lean Alpine-based image. Only runtime dependencies.
# ─────────────────────────────────────────────────────────────────────────
FROM php:8.3-fpm-alpine AS production
# Install runtime system packages:
# nginx - HTTP server to accept incoming requests
# supervisor - process manager to run both nginx and php-fpm
# sqlite - SQLite runtime libraries
RUN apk add --no-cache \
nginx \
supervisor \
sqlite \
&& docker-php-ext-install pdo pdo_sqlite mbstring exif pcntl bcmath
WORKDIR /var/www/html
# Copy the compiled application from the builder stage.
# Crucially, we do NOT copy node_modules or dev dependencies.
COPY --from=builder /app /var/www/html
# Copy configuration files
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/php.ini /usr/local/etc/php/conf.d/custom.ini
COPY docker/supervisord.conf /etc/supervisord.conf
# Create the SQLite database directory.
# The actual .sqlite file will come from a PersistentVolumeClaim in k8s.
# Locally it will be mounted via Docker volume.
RUN mkdir -p /var/www/html/database
# Fix storage permissions.
# www-data is the nginx/php-fpm user.
RUN chown -R www-data:www-data \
/var/www/html/storage \
/var/www/html/bootstrap/cache \
/var/www/html/database \
&& chmod -R 775 \
/var/www/html/storage \
/var/www/html/bootstrap/cache \
/var/www/html/database
# Optimise Laravel for production
RUN php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache
EXPOSE 80
# Supervisord starts both nginx and php-fpm
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
3.3 Nginx Configuration
Nginx acts as the front-facing HTTP server inside the container. It serves static assets (CSS, JS, images) directly from disk and proxies all PHP requests to PHP-FPM via FastCGI.
docker/nginx.conf
# We use the events block even though we are not customising it.
# Nginx requires it.
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging — output to stdout/stderr so kubectl logs works
access_log /dev/stdout;
error_log /dev/stderr warn;
# Basic performance settings
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain application/json application/javascript text/css;
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
# Laravel's try_files pattern:
# 1. Try to serve the exact file ($uri)
# 2. Try to serve as a directory ($uri/)
# 3. Fall back to index.php with the query string
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Send all .php files to PHP-FPM running on port 9000
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_read_timeout 300;
}
# Deny access to .htaccess and other hidden files
location ~ /\.ht {
deny all;
}
# Deny direct access to the SQLite database file
location ~ \.sqlite$ {
deny all;
}
}
}
3.4 PHP Configuration
docker/php.ini
; Production PHP settings for a Laravel API
; Memory limit — increase for large Eloquent operations
memory_limit = 256M
; Maximum execution time in seconds
max_execution_time = 60
; Maximum POST body size (for file uploads via API)
post_max_size = 20M
upload_max_filesize = 20M
; Hide PHP version from response headers (security)
expose_php = Off
; Disable error display in responses; log to stderr instead
display_errors = Off
log_errors = On
error_log = /dev/stderr
; OPcache — dramatically speeds up PHP by caching compiled bytecode
opcache.enable = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 8
opcache.max_accelerated_files = 10000
opcache.revalidate_freq = 0 ; never recheck files in production
opcache.validate_timestamps = 0
3.5 Supervisord Configuration
Kubernetes expects each container to run a single foreground process. Since we need both Nginx and PHP-FPM running, we use Supervisord as a process manager to start and monitor both.
docker/supervisord.conf
[supervisord]
; Run in the foreground so Docker can track the process
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
[program:php-fpm]
command=/usr/local/sbin/php-fpm --nodaemonize
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
#laravel13 #DEVOPS #Kubernetes #laravelDeployment #fullstackdevelopment
Top comments (0)