DEV Community

Cover image for Deploying Laravel with Docker, GitHub Actions, and Kubernetes
Akandwanaho Alvin
Akandwanaho Alvin

Posted on

Deploying Laravel with Docker, GitHub Actions, and Kubernetes

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"]

Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

#laravel13 #DEVOPS #Kubernetes #laravelDeployment #fullstackdevelopment

Top comments (0)