← All writing

Docker Images for Laravel in Production: Multi-Stage Builds, Non-Root Users, and What Actually Ships

Most Laravel Docker tutorials stop at getting the app to start. Production images require multi-stage builds to remove build-time tools, a non-root user to reduce the blast radius of a compromise, and explicit controls over what is in the image and how secrets reach the container.


The gap between a Docker image that works locally and one that is safe to run in production is wider than most Laravel tutorials acknowledge.

Getting a Laravel application to boot inside a container is straightforward. Getting it to boot securely, from an image that contains only what it needs, with secrets that never appear in a layer, from a build that will not silently change under you — that takes deliberate decisions that the quickstart guides skip.

This is the set of decisions worth making before a Laravel container goes into a production environment.

Multi-stage builds: separating build from runtime

A naive Dockerfile that installs Composer, copies source files, and runs composer install in the same layer produces an image that ships the Composer binary, dev dependencies, PHPUnit, and build-time environment variables into production. None of that belongs there.

A multi-stage build separates the construction step from what gets deployed:

# ── Stage 1: Build ───────────────────────────────────────────────────────────
FROM php:8.3-cli-alpine AS builder

WORKDIR /app

# Install only the extensions Composer and the build step need
RUN apk add --no-cache git unzip \
    && docker-php-ext-install pdo_mysql

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

COPY composer.json composer.lock ./
RUN composer install \
    --no-dev \
    --no-scripts \
    --no-interaction \
    --prefer-dist \
    --optimize-autoloader

COPY . .
RUN php artisan config:clear \
    && php artisan route:clear \
    && php artisan view:clear

# ── Stage 2: Runtime ──────────────────────────────────────────────────────────
FROM php:8.3-fpm-alpine AS runtime

WORKDIR /app

RUN apk add --no-cache nginx tini \
    && docker-php-ext-install pdo_mysql opcache

# Only copy the application code and vendor from the build stage
COPY --from=builder /app /app

# Baked-in OPcache config for production
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini

EXPOSE 8080

The runtime stage receives no Composer binary, no development dependencies, no git history, and no build-time environment variables. It contains only what the application needs to serve requests.

Non-root users: limiting the blast radius

PHP-FPM and Nginx run as root by default in their official images. If application code or a vulnerable dependency achieves remote code execution, a root process can write to the host filesystem, read container secrets from the environment, and in some configurations escape the container entirely.

Running as a non-root user does not prevent a compromise, but it substantially limits what a successful attack can do:

FROM php:8.3-fpm-alpine AS runtime

WORKDIR /app

# Create a dedicated application user
RUN addgroup -g 1001 -S appgroup \
    && adduser -u 1001 -S appuser -G appgroup

# Ensure the application can write only to what it needs to
RUN chown -R appuser:appgroup /app \
    && mkdir -p /var/log/nginx /run/nginx \
    && chown -R appuser:appgroup /var/log/nginx /run/nginx

COPY --from=builder --chown=appuser:appgroup /app /app

USER appuser

storage/ and bootstrap/cache/ must be writable by this user. Everything else — source files, vendor, and config — should be read-only.

Immutable image references in Compose and CI

Image tags like myapp:latest or even myapp:1.4.2 are mutable. A registry can replace them silently. A docker-compose pull during a deploy may fetch a different image than the one you tested.

Pin to SHA256 digests for anything that touches production:

# docker-compose.production.yml
services:
  app:
    image: registry.example.com/myapp@sha256:a3f1c2b9d4e...
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp
      - /app/storage/framework/cache
      - /app/storage/framework/sessions
      - /app/storage/framework/views

The read_only: true flag mounts the container filesystem as read-only. The tmpfs entries give the runtime the writable directories Laravel needs without persisting them to the host. A Horizon worker or queue consumer that writes unexpectedly outside those paths will fail visibly rather than silently modifying the image’s overlayfs layer.

Secrets: what must not appear in a layer

Every RUN instruction creates a filesystem layer. Any environment variable set with ENV in a Dockerfile is visible in the image’s manifest. Any secret written to a file during the build, then deleted, survives in the layer history.

For application secrets, use runtime environment injection — not build arguments:

# docker-compose.production.yml
services:
  app:
    image: registry.example.com/myapp@sha256:a3f1c2b9d4e...
    env_file: .env.production   # mounted at deploy time, not baked into the image
    environment:
      APP_ENV: production
      LOG_CHANNEL: stderr

If secrets come from a vault (AWS Secrets Manager, HashiCorp Vault, 1Password Secrets Automation), inject them as environment variables at container startup, not as ARG values during the build. Docker’s build secrets (--secret flag) are appropriate for build-time credentials like a private Packagist token — they are never stored in a layer.

Health checks: a container running is not a container ready

docker-compose and orchestrators like ECS mark a container as healthy based on the process status, not on whether PHP-FPM is actually serving responses. A container that started but hangs during framework boot will be marked healthy and receive traffic.

Add a health check that exercises the actual HTTP path:

services:
  app:
    image: registry.example.com/myapp@sha256:a3f1c2b9d4e...
    healthcheck:
      test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8080/up"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

Laravel’s health check route (/up) is a minimal endpoint that verifies the framework bootstrapped correctly without exercising a database query. The start_period gives the PHP-FPM process time to initialise before the first health evaluation runs.

A production readiness checklist

Before tagging an image for production:

[ ] Multi-stage build: Composer and dev tools not present in runtime stage
[ ] Runtime image runs as a non-root user (UID 1001+)
[ ] OPcache enabled with preload where appropriate
[ ] Image pinned to a SHA256 digest in Compose/ECS task definition
[ ] Secrets injected at runtime, not baked into ENV or ARG during build
[ ] Storage directories writable; everything else read-only
[ ] Health check configured on the actual HTTP endpoint
[ ] Logs written to stdout/stderr (not to storage/logs/)
[ ] Resource limits (memory, CPU) set in the service definition
[ ] Image scanned for known CVEs before promotion to production registry

The gap between “starts locally” and “safe in production” is mostly these decisions. None of them are exotic, and most of them have no runtime cost once they are in place.