Dockerize a Laravel App for Production Multi-Stage Builds

Khimananda Oli 9 min read CI/CD and Automation
Dockerize a Laravel App for Production Multi-Stage Builds

By Khimananda Oli | Last reviewed: August 2026

You need to Dockerize a Laravel app for production multi-stage builds because single-stage images ship gigabytes of build tools, source maps, and dev dependencies into your runtime environment. This bloat increases attack surface, slows CI/CD pipelines, and wastes bandwidth during deploys to regions like Nepal where egress costs matter. The solution is a disciplined multi-stage Dockerfile that separates compilation from execution, producing a minimal, read-only artifact that passes security audits and scales reliably on Kubernetes or bare-metal VPS.

Stage 1: Depscomposer install --no-devnpm ci && npm run buildFull toolchain presentStage 2: AssetsVite manifest generatedpublic/build/ finalizedNode modules discardedStage 3: RuntimeAlpine + Nginx + PHP-FPMOnly vendor/ & public/Non-root www-data userFinal Image: ~180MB vs 1.2GB Single-Stage
Three-stage pipeline to Dockerize a Laravel app for production multi-stage builds: dependencies, assets, and lean runtime.

How do you structure a Dockerfile to Dockerize a Laravel app for production multi-stage builds?

The key to a successful production image is isolation. Never run composer install or npm run build in your final stage. These commands pull in hundreds of megabytes of compilers, headers, and debug symbols that have no business existing in a container serving HTTP traffic. When I audit teams' infrastructure for SOC 2 compliance, finding build tools in production images is an immediate flag—it expands the vulnerability surface unnecessarily.

Define explicit stages with named contexts

Name every stage so you can reference them in COPY --from directives without relying on fragile numeric indices. This also makes the Dockerfile self-documenting for new engineers joining your team.

# Stage 1: Install PHP and Node dependencies
FROM composer:2 AS vendor-deps
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --ignore-platform-reqs

FROM node:20-alpine AS asset-builder
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

Note the --no-scripts flag in Composer. Laravel’s post-install scripts often try to clear caches or generate keys, which fail when the full application context isn’t present yet. Defer these to an entrypoint script at runtime. Similarly, --ignore-platform-reqs prevents failures when building on Alpine but targeting a glibc-based runtime, though matching base OS across stages avoids subtle extension mismatches.

Assemble the final runtime layer

Your final stage should start from the smallest possible base that includes both PHP-FPM and Nginx. The php:8.4-fpm-alpine image is ideal, but you must add Nginx manually since official PHP images don’t bundle it. Alternatively, use community-maintained images like webdevops/php-nginx, but verify their update cadence before adopting in production.

FROM php:8.4-fpm-alpine AS runtime
RUN apk add --no-cache nginx shadow && \
    docker-php-ext-install pdo_mysql opcache bcmath && \
    usermod -u 1000 www-data && \
    mkdir -p /var/www/html/storage/framework/{sessions,views,cache} && \
    chown -R www-data:www-data /var/www/html

WORKDIR /var/www/html
COPY --from=vendor-deps /app/vendor ./vendor
COPY --from=asset-builder /app/public/build ./public/build
COPY . /var/www/html
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/php.ini /usr/local/etc/php/conf.d/99-production.ini
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

USER www-data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8080/up || exit 1
ENTRYPOINT ["entrypoint.sh"]

This configuration runs as www-data (UID 1000), exposes port 8080 (non-privileged), and includes a health check endpoint. The /up route should be defined in your Laravel app to return 200 without database dependencies, enabling safe load balancer probes during deployments. For deeper guidance on securing this setup, see my article on Ubuntu security hardening, which covers filesystem permissions applicable inside containers too.

Why does image size matter when you Dockerize a Laravel app for production multi-stage builds?

Image size directly impacts deploy velocity, cold start latency, and storage costs. In environments with limited bandwidth—common in South Asian data centers or edge locations—a 1GB image takes minutes to pull versus seconds for a 200MB one. During rolling updates on Kubernetes, large images cause prolonged transition windows where old and new pods coexist, increasing memory pressure and risk of OOM kills.

MetricSingle-Stage ImageMulti-Stage OptimizedImpact
Total Size1.1 – 1.4 GB160 – 220 MB85% reduction in registry storage & egress fees
Pull Time (100 Mbps)~90 seconds~15 secondsFaster autoscaling & recovery
CVE Count (Trivy High+)45 – 1203 – 12Simpler compliance evidence collection
Attack SurfaceGCC, Node, Python, GitPHP-FPM, Nginx, libcFewer exploit paths for RCE

Beyond raw size, layer caching efficiency matters. By isolating dependency installation from source code copying, unchanged composer.lock files reuse cached layers even when application code changes. This cuts CI build times from 4+ minutes to under 30 seconds for typical commits. Always order instructions from least-frequent to most-frequent change: OS packages → extensions → Composer deps → NPM deps → application source.

How do you handle secrets and environment variables securely in production containers?

Never bake .env files into your Docker image. Secrets embedded in layers persist forever in registry history, even after deletion. Instead, inject configuration at runtime via orchestrator-native mechanisms: Kubernetes Secrets mounted as env vars or files, AWS Parameter Store fetched by entrypoint scripts, or HashiCorp Vault agent sidecars. Your Dockerfile should contain zero sensitive values.

External Secret StoreAWS Secrets ManagerHashiCorp VaultK8s External SecretsEncrypted at restAudit loggedOrchestrator LayerK8s Secret / ConfigMapECS Task DefinitionCloud Run Env VarsInjected at pod startNever written to diskContainer Runtime$_ENV populatedLaravel config:cacheNo .env file on diskRead-only root FSNon-root processSecrets flow unidirectionally: store → orchestrator → memory
Secure secret injection pattern when you Dockerize a Laravel app for production multi-stage builds: no baked-in credentials.

Your entrypoint script should validate required variables exist before starting services. Fail fast with descriptive errors rather than letting Laravel boot with null DB connections. Also run php artisan config:cache and route:cache here—not in the Dockerfile—since cached configs embed resolved env values and break if reused across environments. For teams managing multiple databases, refer to PostgreSQL administration essentials for connection pooling patterns compatible with containerized PHP apps.

What runtime optimizations are critical after you Dockerize a Laravel app for production multi-stage builds?

A correctly built image is necessary but insufficient. You must tune PHP-FPM, Nginx, and Laravel itself for container constraints. Default configurations assume bare-metal resources and will bottleneck under load or waste memory in auto-scaled environments.

  • PHP-FPM Process Manager: Set pm = dynamic with pm.max_children calculated as (container_memory_limit - 50MB) / average_process_size. For a 512MB limit with 40MB workers, cap at 11 children. Use pm.start_servers = pm.min_spare_servers = 2 to reduce cold-start latency without over-provisioning.
  • Opcache Configuration: Enable opcache.enable_cli=1 for queue workers and schedulers. Set opcache.memory_consumption=128 and opcache.interned_strings_buffer=16. Crucially, disable opcache.validate_timestamps in production—your filesystem is immutable, so revalidation wastes CPU cycles.
  • Nginx Buffer Tuning: Match fastcgi_buffer_size and fastcgi_buffers to your typical response size. Laravel API responses average 8–16KB; set fastcgi_buffer_size 16k and fastcgi_buffers 4 16k. Oversized buffers consume RAM per connection; undersized ones trigger disk buffering and latency spikes.
  • Queue Worker Isolation: Run queues in separate containers or processes, not alongside FPM. Use php artisan queue:work --daemon with --max-jobs=1000 to prevent memory leaks. Scale workers independently based on queue depth metrics, not HTTP traffic.

Monitoring is non-negotiable. Instrument your container with Prometheus exporters for PHP-FPM and Nginx metrics. Track fpm_active_processes, fpm_listen_queue_len, and nginx_http_requests_total to detect saturation before users experience errors. My guide on Prometheus metrics monitoring fundamentals details which signals predict Laravel container failures.

How does multi-stage compare to single-stage for Laravel production deployments?

Some teams still use single-stage builds for simplicity, arguing that disk space is cheap. This ignores operational realities beyond storage costs. Below is a direct comparison based on production incidents I’ve resolved in 2025–2026.

Single-Stage Build✗ Node.js + GCC in runtime✗ .env often copied accidentally✗ Dev dependencies exposed✗ Slow pulls delay rollbacks✗ Large CVE scan reports✗ Cache invalidates on code changeAvg Size: 1.3 GBDeploy Time: 90s+Multi-Stage Build✓ Only PHP-FPM + Nginx binary✓ Secrets injected at runtime✓ Zero dev packages in final layer✓ Sub-20s pulls enable fast recovery✓ Minimal CVE footprint✓ Deps cached independentlyAvg Size: 190 MBDeploy Time: <15s
Operational impact comparison when you Dockerize a Laravel app for production multi-stage builds versus legacy single-stage approaches.

The multi-stage approach requires upfront investment in Dockerfile design and entrypoint scripting, but pays dividends immediately upon first production incident. Rollback speed alone justifies the effort: when a bad deploy causes P0 errors, waiting 90 seconds per node for image pulls extends outage duration unacceptably. In regulated environments, the reduced CVE count simplifies audit evidence collection and shortens review cycles.

Production Readiness Checklist for Containerized Laravel

Before shipping your multi-stage Laravel image, verify these items. Missing any one has caused outages in systems I’ve inherited or audited.

  1. Read-only root filesystem: Mount /tmp and storage/ as writable volumes; set readOnlyRootFilesystem: true in Kubernetes PodSecurityContext. Prevents attackers from writing webshells.
  2. Graceful shutdown handling: Configure PHP-FPM process_control_timeout=10 and Nginx worker_shutdown_timeout 10s. Ensure entrypoint traps SIGTERM and waits for in-flight requests.
  3. Structured logging: Output JSON logs to stdout/stderr. Never write to files inside the container. Aggregate externally via Fluent Bit or Vector.
  4. Resource limits: Set CPU/memory requests and limits in orchestration config. Test with load to avoid throttling or OOMKills during peak.
  5. Dependency pinning: Lock PHP, Nginx, and Alpine versions explicitly. Avoid latest tags. Rebuild monthly to incorporate security patches.

When you properly Dockerize a Laravel app for production multi-stage builds, you gain more than smaller images—you establish a foundation for secure, observable, and compliant operations. If your team needs help implementing this pattern or passing a security audit with containerized PHP workloads, reach out to discuss your specific architecture.

Frequently Asked Questions

Multi-stage builds separate dependency installation from the final runtime image. This excludes node_modules, build tools, and source maps, reducing production image size by over 60% and minimizing the attack surface by removing unnecessary binaries and development packages from the deployed container.

Use php:8.4-fpm-alpine for the final stage due to its small footprint and security patches. For the build stage, node:22-alpine handles frontend asset compilation efficiently. Alpine variants reduce layer size significantly compared to Debian-based images while maintaining full compatibility with Laravel 12 and modern PHP extensions.

Compile assets in a dedicated Node stage using npm ci and npm run build. Copy only the built manifest and assets into the PHP runtime stage via COPY --from=builder. This prevents Node.js and source files from bloating the final production image or creating unnecessary cache invalidation layers.

Install vendor dependencies in an intermediate Composer stage using composer install --no-dev --optimize-autoloader. Copy only the vendor directory to the final runtime stage. This avoids shipping dev dependencies, reduces image size, and ensures autoload optimization happens before deployment without requiring Composer in production.

Never bake .env files into any build stage. Inject secrets at runtime via orchestrator secret managers or mounted volumes. Use ARG only for non-sensitive build-time configuration like APP_ENV. Runtime injection ensures credentials never appear in image layers, build logs, or registry artifacts.

Initial builds take longer due to multiple stages, but subsequent builds benefit from layer caching. Cache Composer and npm directories between runs using BuildKit cache mounts. Properly ordered instructions ensure unchanged layers skip rebuilding, often making cached multi-stage builds faster than single-stage equivalents in continuous integration environments.

Order Dockerfile instructions from least to most frequently changing. Place system package installs first, then Composer/npm dependency copies, then application code. Use BuildKit cache mounts for package managers. This maximizes cache hits during development and CI, preventing redundant reinstallation of unchanged dependencies across builds.

Set ownership to www-data:www-data with 775 permissions during the final build stage. Create these directories explicitly in the Dockerfile rather than relying on entrypoint scripts. Immutable filesystems require pre-configured writable paths; mount external volumes for persistent storage while keeping the base image read-only and reproducible.

Yes. Use docker buildx build --progress=plain to see full output. Inspect intermediate stages with docker buildx debug or temporary targets. Check BuildKit logs for cache issues. Break complex RUN commands into smaller steps during troubleshooting to isolate failures without rebuilding entire stages repeatedly.

Final images contain only runtime essentials, eliminating compilers, shells, and package managers that attackers exploit. Smaller images mean fewer CVEs to patch. Read-only root filesystems become feasible when build artifacts are copied cleanly. Security scanning tools report significantly fewer vulnerabilities in properly constructed multi-stage Laravel images.

Single-stage images often exceed 800MB with dev tools included. Optimized multi-stage builds typically produce final images between 150MB and 250MB. The reduction comes from excluding Node.js, Composer, build caches, and development dependencies, resulting in faster deployments and lower registry storage costs.

Yes.

Use the same optimized production image for both FPM and queue worker containers. Override the entrypoint command to run php artisan queue:work instead of PHP-FPM. This ensures identical code, dependencies, and configurations across all service types while maintaining the security and size benefits of multi-stage builds.

Both work.

Run automated tests against the final image using docker run with test commands. Scan with Trivy or Grype for vulnerabilities. Verify file permissions, extension loading, and asset presence. Integrate validation into CI pipelines to catch misconfigurations before pushing to registries, ensuring only verified images reach production environments.