Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 061 / 100

Dockerizing a Laravel app: practical tips and trade-offs

A production-ready container setup.

Expona Admin8 min read

Day 61: Dockerizing a Laravel app: practical tips and trade-offs

Moving a Laravel application into containers isn't just about running docker build and calling it done. We need to balance image size, security, startup performance, and the operational complexity of managing multiple container orchestration strategies. This article walks through the most common production patterns we've deployed across e-commerce and SaaS systems, including the trade-offs that matter when you're responsible for keeping these applications running.

Why containerize Laravel in the first place?

Before diving into the technical details, let's ground ourselves in why we containerize. Containers give us reproducibility: the same image runs identically on a developer's laptop, in staging, and in production. They simplify dependency management—PHP extensions, system libraries, and application code all bundled together. And they make horizontal scaling straightforward: spin up more container instances as load increases. For teams managing multiple Laravel services, this consistency eliminates the "but it works on my machine" problem.

PHP-FPM image: Installing required extensions safely

PHP-FPM is the de facto standard for running PHP in containerized environments. It separates the PHP process manager from the web server, which gives us flexibility and resilience. When building a PHP-FPM image, the first decision is choosing a base. Official PHP images come in several flavors: php:8.2-fpm-alpine for minimal footprint, or php:8.2-fpm-debian for broader compatibility with system packages.

Alpine-based images are tempting because they're small—often under 150 MB compared to 400+ MB for Debian variants. But they come with trade-offs. Alpine uses musl instead of glibc, which can cause unexpected behavior with certain PHP extensions or native binaries. For production Laravel apps, we typically recommend Debian-based images unless image size is a hard constraint.

Here's a typical starting point for a production Dockerfile:

php
FROM php:8.2-fpm-debian

# Install system dependencies
RUN apt-get update && apt-get install -y \
    git \
    curl \
    libpq-dev \
    libonig-dev \
    libxml2-dev \
    zip \
    unzip \
    && rm -rf /var/lib/apt/lists/*

# Install PHP extensions
RUN docker-php-ext-install \
    pdo_mysql \
    pdo_pgsql \
    mbstring \
    exif \
    pcntl \
    bcmath

# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# Copy application
WORKDIR /var/www/html
COPY . .

# Install dependencies
RUN composer install --no-dev --optimize-autoloader

# Set permissions
RUN chown -R www-data:www-data /var/www/html

EXPOSE 9000

CMD ["php-fpm"]

Notice a few things here. We install extensions using docker-php-ext-install, which handles compilation and configuration automatically. For extensions with external dependencies (like pdo_pgsql), we install system libraries first. We also use --no-dev with Composer to exclude test dependencies, which reduces the final image size and attack surface.

The EXPOSE 9000 line documents that FPM listens on port 9000, but doesn't actually publish it. That's intentional: FPM shouldn't be exposed directly to the internet. We'll put a web server in front of it.

Nginx in front: Serving static files and proxying to FPM

Nginx is our web server of choice for Laravel deployments. It's lightweight, handles static assets efficiently, and proxies requests to PHP-FPM with minimal overhead. Rather than running Nginx and PHP-FPM in the same container (which complicates process management and makes scaling awkward), we run them in separate containers that communicate over a network.

Here's a production-grade Nginx configuration:

php
upstream php_fpm {
    server fpm:9000;
}

server {
    listen 80;
    server_name _;
    root /var/www/html/public;

    # Gzip compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;

    # Static files (cache aggressively)
    location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Deny access to sensitive files
    location ~ /\. {
        deny all;
    }

    location ~ /config/ {
        deny all;
    }

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

    # PHP-FPM proxy
    location ~ \.php$ {
        fastcgi_pass php_fpm;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

This configuration handles several critical tasks. It serves static assets with far-future cache headers (30 days), reducing load on FPM. It denies access to dot-files and configuration directories, hardening the attack surface. It uses Laravel's standard routing pattern (try_files) to pass all requests through index.php. And it proxies PHP requests to the FPM container via the fpm:9000 hostname, which Docker's internal DNS resolves automatically.

The Nginx Dockerfile is simpler:

php
FROM nginx:alpine

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=php-builder /var/www/html /var/www/html

EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

We use daemon off so Nginx runs in the foreground, which is essential for Docker—containers need a foreground process. The --from=php-builder syntax copies files from the PHP image we built earlier, avoiding duplication.

Separate containers for web, queue, and scheduler

One of the biggest architectural wins in containerization is treating each workload independently. A web request handler has different resource requirements than a long-running queue worker or a scheduled task runner. Running them in separate containers lets us scale and resource-limit them individually.

A typical Laravel application needs three persistent processes:

  • Web container: Handles HTTP requests via Nginx + PHP-FPM
  • Queue worker: Processes background jobs from Redis or database
  • Scheduler: Runs scheduled commands at specified intervals

Here's a docker-compose setup that reflects this separation:

php
version: '3.9'

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.web
    ports:
      - "80:80"
    depends_on:
      - fpm
    networks:
      - app-network

  fpm:
    build:
      context: .
      dockerfile: Dockerfile.fpm
    expose:
      - 9000
    environment:
      APP_ENV: production
      LOG_CHANNEL: stdout
    depends_on:
      - postgres
      - redis
    networks:
      - app-network
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/ping || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 3

  queue:
    build:
      context: .
      dockerfile: Dockerfile.fpm
    command: php artisan queue:work --queue=default --timeout=60
    environment:
      APP_ENV: production
      LOG_CHANNEL: stdout
    depends_on:
      - redis
    networks:
      - app-network
    restart: unless-stopped

  scheduler:
    build:
      context: .
      dockerfile: Dockerfile.fpm
    command: php artisan schedule:work
    environment:
      APP_ENV: production
      LOG_CHANNEL: stdout
    depends_on:
      - postgres
      - redis
    networks:
      - app-network
    restart: unless-stopped

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: laravel
      POSTGRES_USER: laravel
      POSTGRES_PASSWORD: secret
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - app-network

  redis:
    image: redis:7-alpine
    networks:
      - app-network

volumes:
  postgres_data:

networks:
  app-network:
    driver: bridge

Notice that queue and scheduler containers use the same Dockerfile.fpm image but override the command. This is efficient: one image, multiple entry points. The health check on the FPM service ensures Nginx knows when the upstream is unhealthy and can serve appropriate error responses. We also set restart: unless-stopped on worker containers so they automatically recover from crashes.

In Kubernetes, this separation becomes even more powerful. You define separate Deployments for web (with 3+ replicas) and workers (with appropriate concurrency), and scale them independently based on load metrics.

FrankenPHP: A modern single-container alternative

FrankenPHP is a relatively new approach that bundles Caddy (a modern HTTP server) with PHP in a single container. Instead of PHP-FPM and a separate web server, FrankenPHP handles both with built-in HTTP/2, automatic HTTPS, and request handling directly in PHP.

The Dockerfile is remarkably simple:

php
FROM dunglas/frankenphp:latest

WORKDIR /app

COPY composer.* ./
RUN composer install --no-dev --optimize-autoloader

COPY . .

RUN chown -R www-data:www-data /app

EXPOSE 80 443

CMD ["frankenphp", "run", "--bind", "0.0.0.0:80"]

FrankenPHP automatically detects Laravel and handles routing. There's no separate Nginx configuration to maintain. Static files are served directly. For simple deployments or when operational simplicity is paramount, FrankenPHP is attractive.

However, there are trade-offs. FrankenPHP is newer than the established Nginx + PHP-FPM pattern, so community support and production battle-testing are still evolving. You lose the architectural flexibility of separating concerns: you can't easily run different numbers of web and worker replicas. If the FrankenPHP process crashes, both your web server and PHP runtime go down. And for very high-traffic applications where Nginx's efficiency at serving static files matters, the separation still has operational value.

We'd recommend FrankenPHP for:

  • Startups and early-stage products where deployment simplicity wins
  • Internal tools and admin panels with modest traffic
  • Development and staging environments

And stick with Nginx + PHP-FPM for:

  • High-traffic production systems handling thousands of RPS
  • Applications with complex caching and static asset strategies
  • Teams comfortable with multi-container architectures

Common mistakes we see in production

Running multiple processes in one container: Some teams add supervisord or use Nginx and PHP-FPM in the same Dockerfile. Docker containers should run a single process (or a tightly coupled pair). Multiple processes complicate logging, health checks, and scaling. Use separate containers.

Not setting resource limits: Without explicit memory and CPU limits in docker-compose or Kubernetes manifests, a runaway PHP process can starve other services. Always define mem_limit and cpus constraints.

Logging to files inside the container: Container filesystems are ephemeral. When the container dies, logs vanish. Configure Laravel to log to stdout (set LOG_CHANNEL=stdout in your environment) so Docker's log driver captures everything.

Including vendor directory in the build: Some teams COPY the entire project including vendor/ and node_modules. This bloats the image. Always run dependency installation inside the Dockerfile and use multi-stage builds to separate build tools from runtime.

Forgetting health checks: Orchestrators like Docker Swarm and Kubernetes need to know when your container is healthy. Add a health check endpoint or use TCP probes so the platform can restart unhealthy instances.

Key takeaways

  • PHP-FPM with Nginx is the production workhorse: proven, flexible, and battle-tested across thousands of deployments
  • Separate web, queue, and scheduler into distinct containers for independent scaling and resource management
  • Use multi-stage Dockerfiles to keep images lean: compile dependencies, then copy only what's needed to the runtime stage
  • Always log to stdout and configure health checks so orchestrators can manage your containers reliably
  • FrankenPHP offers simplicity for smaller deployments but sacrifices architectural flexibility; choose based on your team's scale and expertise
  • Set resource limits and configure restart policies so containers can recover from transient failures automatically
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles