Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Dockerize a Laravel App for Production Multi-Stage Builds

By Kokil Thapa | Last reviewed: August 2026

If you need to ship reliable PHP applications in 2026, you must know how to dockerize a Laravel app for production multi-stage builds. Single-stage Dockerfiles create bloated, insecure images that slow deployments and increase attack surfaces. Multi-stage builds solve this by separating build-time dependencies from runtime artifacts, producing lean containers under 150MB that start in seconds. This approach is now standard practice for any serious Laravel developer deploying to Kubernetes, ECS, or traditional VPS infrastructure.

Why Should You Dockerize a Laravel App for Production Multi-Stage Builds?

Multi-stage builds are not optional for production Laravel deployments—they are the baseline requirement. When you dockerize a Laravel app for production multi-stage builds correctly, you eliminate three critical problems that plague single-stage approaches.

First, image size drops dramatically. A naive Laravel Dockerfile often exceeds 800MB because it includes Node.js, npm cache, Composer cache, and development tools. Multi-stage builds discard these after extracting only compiled assets and vendor dependencies. I regularly see production images between 90MB and 140MB using Alpine-based PHP-FPM. Smaller images mean faster CI/CD pipelines, reduced registry storage costs, and quicker horizontal scaling during traffic spikes.

Second, security posture improves substantially. Build tools like npm, git, and compilers contain known vulnerabilities. By excluding them from the final image, you reduce the CVE surface area. Security scanners consistently report fewer findings on multi-stage Laravel images. For legal-tech portals handling sensitive client documents, this reduction matters for compliance and trust.

Third, reproducibility becomes guaranteed. Every build starts from identical base images with pinned versions. The "works on my machine" problem disappears because the container encapsulates the exact PHP extensions, system libraries, and configuration your application requires. On projects like Nepal Gift Card and Adventure Third Pole Trek, this consistency eliminated an entire category of deployment failures caused by environment drift.

Single-Stage (Avoid)PHP + Node + Composer + npm cacheDev tools + git + compilersAll source files + .git directoryRuntime: vendor/ + public/build/~850 MBHigh CVE surface • Slow pullsMulti-Stage (Production)Stage 1: composer install --no-devStage 2: npm ci && npm run buildFinal Stage: php-fpm-alpineCOPY vendor/ + public/build/ ONLY~120 MBMinimal CVEs • Fast scalingReproducible • Secure
Single-stage Dockerfiles include build tools in production; multi-stage builds copy only runtime artifacts for smaller, safer Laravel containers

How Do You Write an Optimized Multi-Stage Dockerfile for Laravel 12?

The Dockerfile structure determines everything about your production image quality. Below is a battle-tested template I use across client projects running Laravel 11 and 12 on PHP 8.3 and 8.4. Each stage has a specific purpose, and layer ordering maximizes Docker cache efficiency.

Complete Production Dockerfile

# syntax=docker/dockerfile:1.7
FROM composer:2.7 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist

FROM node:22-alpine AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY resources ./resources
COPY vite.config.js tailwind.config.js postcss.config.js ./
RUN npm run build

FROM php:8.4-fpm-alpine AS base
RUN apk add --no-cache \
    nginx \
    supervisor \
    libpng-dev \
    libjpeg-turbo-dev \
    freetype-dev \
    oniguruma-dev \
    libxml2-dev \
    zip \
    unzip \
    git && \
    docker-php-ext-configure gd --with-freetype --with-jpeg && \
    docker-php-ext-install -j$(nproc) \
        pdo_mysql \
        mbstring \
        xml \
        opcache \
        bcmath \
        pcntl \
        gd && \
    apk del .build-deps && \
    rm -rf /var/cache/apk/*

COPY --from=vendor /app/vendor /app/vendor
COPY --from=frontend /app/public/build /app/public/build

WORKDIR /app
COPY . .

RUN cp .env.example .env && \
    php artisan optimize && \
    php artisan config:cache && \
    php artisan route:cache && \
    php artisan view:cache && \
    chown -R www-data:www-data /app/storage /app/bootstrap/cache && \
    chmod -R 775 /app/storage /app/bootstrap/cache

COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/php.ini /usr/local/etc/php/conf.d/99-production.ini

EXPOSE 8080
USER www-data
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

Critical Design Decisions Explained

  • Dependency stages first: Composer and npm stages run before copying application code. Changing your controllers does not invalidate the vendor layer cache. This alone saves 2-4 minutes per rebuild during active development.
  • --no-dev flag: Never install PHPUnit, Faker, or debugging packages in production. These add 30-50MB and introduce unnecessary code paths.
  • Alpine base: php-fpm-alpine produces images roughly 60% smaller than Debian-based equivalents. The tradeoff is musl libc instead of glibc, which rarely affects Laravel applications but test thoroughly if you use binary extensions.
  • Optimization at build time: Running php artisan optimize, config:cache, route:cache, and view:cache during build means every container starts pre-warmed. Avoid running these in entrypoint scripts—they execute on every container start and waste precious seconds during scaling events.
  • Non-root user: Running as www-data prevents privilege escalation attacks. Ensure storage/ and bootstrap/cache/ ownership is set before switching users.

This pattern scales well whether you are building a simple brochure site or a complex platform like high-performance Laravel e-commerce stores. The key discipline is treating the Dockerfile as infrastructure code that deserves the same attention as your application logic.

What Is the Best Nginx and PHP-FPM Configuration for Containerized Laravel?

Embedding Nginx and PHP-FPM in a single container simplifies deployment for small-to-medium applications. For larger systems, separate services offer better scaling granularity, but the embedded approach works excellently for most Laravel projects I ship.

Nginx Configuration for Laravel Containers

server {
    listen 8080 default_server;
    root /app/public;
    index index.php;

    charset utf-8;
    client_max_body_size 64M;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_buffering on;
        fastcgi_buffer_size 16k;
        fastcgi_buffers 16 16k;
        fastcgi_read_timeout 300;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }

    access_log off;
    error_log /dev/stderr warn;
}

Supervisor Process Management

[supervisord]
nodaemon=true
user=root
logfile=/dev/null
pidfile=/var/run/supervisord.pid

[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

Logging to stdout/stderr is mandatory. Container orchestrators capture standard streams; writing to files inside containers creates orphaned logs that vanish when the pod terminates. For deeper insight into structuring backend services properly, review building REST APIs in Laravel the right way, as API-first architectures pair naturally with containerized deployments.

Laravel Production Container (port 8080)NginxStatic files: /app/publicProxy .php → FPM socketLogs → /dev/stdoutPHP-FPM 8.4Workers: pm.dynamicOPcache enabledUnix socket (no TCP)SupervisorManages both processesAuto-restart on crashGraceful shutdownunix sockApplication Layer (Read-Only)/app/vendor • /app/public/buildCached config/routes/views baked inWritable Volume Mount/app/storage (logs, cache, sessions)Persisted across restartsHealth check: GET /up → 200 OK • Entrypoint: supervisord
Container internals: Nginx proxies to PHP-FPM via Unix socket, Supervisor manages processes, writable storage is volume-mounted separately from read-only application code

How Do You Handle Environment Variables and Secrets in Dockerized Laravel?

Secret management causes more production incidents than almost any other Docker topic. Never bake secrets into images. The .env file copied during build should contain only non-sensitive defaults sufficient for artisan optimize to succeed. Real credentials come from the orchestration layer at runtime.

Runtime Environment Injection Pattern

  1. Build-time .env: Include APP_NAME, APP_ENV=production, CACHE_DRIVER=file, SESSION_DRIVER=file. Exclude database passwords, API keys, mail credentials.
  2. Runtime override: Pass secrets via Docker environment variables, Kubernetes Secrets, AWS Parameter Store, or HashiCorp Vault. Laravel's env() helper reads process environment before falling back to .env values.
  3. Config caching caveat: If you run config:cache during build, env() calls outside config/ files break. Either skip config caching (acceptable for most apps) or ensure all env() usage lives in config/ files where it gets resolved at cache-generation time.
  4. Entrypoint flexibility: For advanced setups, use an entrypoint script that generates .env from injected secrets before starting Supervisor. This supports platforms that cannot inject individual environment variables easily.
# docker-compose.prod.yml snippet
services:
  laravel:
    image: registry.example.com/myapp:v2.4.1
    environment:
      - DB_HOST=mysql.internal
      - DB_PASSWORD=${DB_PASSWORD}
      - REDIS_HOST=redis.internal
      - MAIL_MAILER=smtp
      - MAIL_PASSWORD=${MAIL_PASSWORD}
    volumes:
      - laravel-storage:/app/storage
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/up"]
      interval: 30s
      timeout: 5s
      retries: 3

This separation ensures compromised images do not leak credentials and allows the same image artifact to serve staging, production, and disaster-recovery environments with different configurations. For teams managing multiple client projects, this pattern reduces operational overhead significantly compared to maintaining separate builds per environment.

What Are Common Mistakes When Dockerizing Laravel Applications?

After reviewing dozens of Laravel Dockerfiles from teams across Nepal and internationally, certain anti-patterns appear repeatedly. Avoiding these saves hours of debugging and prevents production incidents.

MistakeImpactCorrect Approach
Running as root userPrivilege escalation risk; file permission conflicts on mounted volumesAdd USER www-data after setting ownership of storage/ and bootstrap/cache/
Installing dev dependencies+40MB image size; unnecessary attack surface; slower autoloaderAlways use composer install --no-dev in vendor stage
Skipping PHP extension verificationRuntime crashes when missing extensions are calledRun php -m in CI pipeline; fail build if required extensions absent
Using COPY . . without .dockerignore.git/, tests/, node_modules/ included; cache invalidation on every commitCreate comprehensive .dockerignore excluding non-runtime files
Running migrations in entrypointRace conditions during rolling deploys; failed containers block startupRun migrations as separate CI/job step before deploying new image tag
Hardcoding ports or pathsCannot reuse image across environments; brittle configurationUse environment variables for configurable values; sensible defaults only

The migration mistake deserves special emphasis. Running php artisan migrate --force in an entrypoint script seems convenient but creates dangerous race conditions during zero-downtime deployments. Two containers might attempt schema changes simultaneously, or a new container might start before migrations complete and serve errors. Treat migrations as a discrete deployment phase, not a container startup task. This aligns with how mature teams handle modern Laravel architecture best practices where deployment concerns are separated from application runtime.

Start: Laravel Docker DeployExpected RPS < 500 AND team < 5 engineers?YESNOEmbedded Nginx+FPM✓ Simpler ops✓ Single container deploy✗ Coupled scalingSidecar / Separate Services✓ Independent scaling✓ Dedicated TLS termination✗ More complex networkingUse Supervisord pattern aboveK8s Ingress / Envoy / Traefik
Decision tree for selecting embedded versus sidecar web server topology based on traffic volume and team operational capacity

Deploying Your Dockerized Laravel Application Successfully

When you dockerize a Laravel app for production multi-stage builds, the final validation step matters as much as the Dockerfile itself. Before promoting any image to production, verify these checkpoints:

  • Image size audit: Run docker images and confirm the final tag is under 200MB. If larger, inspect layers with dive to find bloat.
  • Security scan: Integrate Trivy or Grype into CI. Block merges on HIGH or CRITICAL findings in the final stage.
  • Health endpoint: Implement a dedicated /up route that checks database connectivity and returns 200. Configure orchestrator health checks against this endpoint, not the homepage.
  • Log format: Confirm structured JSON logging is active. Unstructured logs make production debugging painful at scale.
  • Permission test: Exec into a running container as www-data and verify write access to storage/logs, storage/framework/cache, and storage/framework/sessions.

For teams transitioning from traditional VPS deployments, the learning curve is real but manageable. Start with Docker Compose for staging, validate the workflow, then graduate to ECS, Cloud Run, or Kubernetes for production. The investment pays compounding returns through faster deployments, consistent environments, and simplified onboarding for new developers joining your team.

If you are planning to dockerize a Laravel app for production multi-stage builds and want hands-on guidance tailored to your infrastructure, reach out to discuss your deployment architecture. I help teams across Nepal and globally modernize their Laravel hosting with practical, maintainable container strategies that respect budget and operational reality.

Frequently Asked Questions

A multi-stage build uses separate stages in one Dockerfile to compile assets and install dependencies, then copies only production artifacts into a minimal final runtime image.

Single-stage images include Node, dev dependencies, and source maps, bloating size and attack surface. Multi-stage keeps the final image under 200MB with only PHP-FPM, Composer production packages, and compiled assets.

Use php:8.4-fpm-alpine or php:8.3-fpm-alpine as the final stage. Alpine reduces image size significantly compared to Debian-based images while supporting all required Laravel extensions like pdo_mysql, bcmath, and redis.

Create a node:22-alpine stage that runs npm ci and npm run build, then COPY the resulting public/build directory into the final PHP-FPM stage. This avoids installing Node.js in your production container entirely while ensuring Vite manifests are correctly generated.

Always run composer install --no-dev --optimize-autoloader inside a dedicated build stage. Copying vendor from the host risks platform mismatch issues, especially when developing on macOS ARM but deploying to Linux AMD64 servers. The build stage ensures consistent dependency resolution against the target architecture.

Never bake .env files into images. Pass variables at runtime via docker compose environment keys or Kubernetes ConfigMaps. For secrets like APP_KEY and database credentials, use Docker secrets or external vaults. Run php artisan config:cache during entrypoint initialization, not during build, since cached configs embed environment values.

The www-data user must own storage and bootstrap/cache directories. Add RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache in your final stage. When mounting volumes, ensure the host UID matches the container user or use named volumes initialized with correct ownership during first boot.

Run php artisan queue:work as a separate service using the same production image. Set --tries=3 --timeout=60 --memory=256 flags. Use supervisor or s6-overlay for process management if running multiple workers in one container, though dedicated queue containers scale better. Never run queues inside the PHP-FPM container itself.

Negligible CPU overhead, roughly 1-3% versus native PHP-FPM. Memory adds 10-20MB per container for the runtime. The real trade-off is operational complexity versus deployment consistency. On projects like Nepal Gift Card, containerization eliminated environment drift between staging and production that previously caused post-deploy debugging sessions.

Never auto-run migrations on container start. Execute php artisan migrate --force as a one-off docker compose exec command or CI pipeline step after deployment. Auto-migrations risk partial schema changes during rolling updates when multiple containers start simultaneously. Always backup before migrating in production, regardless of deployment method.

Cache config, routes, and views during container startup via an entrypoint script, not during docker build. Cached files reference runtime environment variables that differ between environments. Clear caches on deploy by running php artisan optimize:clear before restarting containers. Redis should handle application caching separately from these framework-level optimizations.

Override the entrypoint temporarily with docker compose run --entrypoint sh app to inspect the filesystem and test commands manually. Check PHP-FPM logs at /var/log/php-fpm.log and Laravel logs at storage/logs/laravel.log. Common causes include missing extensions, wrong file permissions, or environment variables not being passed correctly to the runtime stage.

For solo developers or small teams, Docker adds setup time but eliminates server configuration drift. If you maintain multiple client sites on shared infrastructure, standardizing on containers pays off quickly. For single simple sites, traditional Deployer-based deployment may be more practical. Evaluate based on team size and project count, not hype.

Leverage BuildKit cache mounts for Composer and npm directories. Add --mount=type=cache,target=/root/.composer to composer install and similar for npm. Order Dockerfile layers from least to most frequently changing: system packages, extensions, composer.lock, composer.json, source code. This maximizes layer caching between builds.

Run as non-root user www-data, never root. Remove shell access in final stage with RUN rm /bin/sh. Scan images with trivy or grype before deployment. Keep base images updated monthly. Restrict filesystem to read-only where possible, mounting only storage and cache as writable. Never expose debug endpoints or include xdebug in production images.

Share this article

Quick Contact Options
Choose how you want to connect me: