Professional Production Dockerization for Laravel: Multi-stage Build, Worker, and Scheduler

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

The Problem: Why Basic Laravel Dockerization Isn’t Enough

After more than 6 months of running Laravel projects in production environments, I realized one thing: a Dockerfile that “works” and a Dockerfile that “runs well” are two completely different stories.

When I first started, I often bundled PHP-FPM and Nginx into a single container. The result was an image weighing nearly 1.2GB. Every time I deployed, the image pull speed was frustratingly slow. Managing Queue Workers or Cronjobs at that time was also very manual and unprofessional.

If your container is bloating or you don’t know how to run php artisan schedule:run correctly in Docker, here is the solution. We will build a stack that optimizes size and clearly separates services.

Core Concepts: Multi-stage Build and Single Responsibility

What is a Multi-stage Build?

Simply put, a Multi-stage build allows you to divide your Dockerfile into multiple stages. You use a stage full of tools (Composer, Node.js) to build your code. Then, you only copy what is necessary, such as the vendor folder, to an extremely lightweight Alpine stage for execution. This technique helps reduce the image size from 800MB to only about 150MB.

Why Separate Multiple Containers?

In Docker, each container should do only one thing well. This makes the system easier to scale and provides better error isolation:

  • App Container: Specializes in handling PHP logic via PHP-FPM.
  • Web Container: Nginx receives requests and forwards them to the App.
  • Worker Container: Processes background jobs via the queue:work command.
  • Scheduler Container: Handles periodic task scheduling.

The interesting part is that the App, Worker, and Scheduler all use the exact same Docker Image. They only differ in their startup command (CMD), ensuring that the code is always 100% consistent across services.

Hands-on: Building a Professional Laravel Stack

1. Writing an Optimized Dockerfile

I chose php:8.2-fpm-alpine as the base. Alpine Linux is the production standard thanks to its high security and ultra-lightweight size.

# Stage 1: Build dependencies
FROM php:8.2-fpm-alpine as backend-builder
WORKDIR /var/www/html

# Install system dependencies
RUN apk add --no-cache libpng-dev libzip-dev zip unzip git curl
RUN docker-php-ext-install pdo_mysql bcmath gd zip

# Copy Composer and install libraries
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
COPY . .
RUN composer install --no-dev --optimize-autoloader --no-scripts

# Stage 2: Runtime image
FROM php:8.2-fpm-alpine
WORKDIR /var/www/html

# Install only necessary runtime dependencies
RUN apk add --no-cache libpng libzip
RUN docker-php-ext-install pdo_mysql bcmath gd zip

# Copy code from the builder stage
COPY --from=backend-builder /var/www/html /var/www/html

# Set standard Laravel permissions
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache

EXPOSE 9000
CMD ["php-fpm"]

2. Configuring Nginx as a Reverse Proxy

Nginx will sit in front to receive traffic. Note the line fastcgi_pass app:9000. Here, app is the service name we will define in the Docker Compose file.

server {
    listen 80;
    index index.php index.html;
    root /var/www/html/public;

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }

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

3. Orchestrating with Docker Compose

This file will coordinate the entire system. You will see the power of using a single build context for many different purposes.

version: '3.8'
services:
  app:
    build: .
    container_name: laravel_app
    restart: unless-stopped
    networks:
      - laravel_network

  web:
    image: nginx:alpine
    container_name: laravel_web
    volumes:
      - ./:/var/www/html
      - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - app
    ports:
      - "8000:80"
    networks:
      - laravel_network

  worker:
    build: .
    container_name: laravel_worker
    command: php artisan queue:work --tries=3
    depends_on:
      - app
    networks:
      - laravel_network

  scheduler:
    build: .
    container_name: laravel_scheduler
    command: sh -c "while [ true ]; do php artisan schedule:run --no-interaction; sleep 60; done"
    depends_on:
      - app
    networks:
      - laravel_network

networks:
  laravel_network:
    driver: bridge

Pro tip: When I need to quickly check JSON logs from the Worker or API, I often use the JSON Formatter from Toolcraft. It helps reformat messy log strings extremely quickly without needing to install heavy extensions.

Performance Upgrades: Caching and Opcache

In a production environment, remove the ./:/var/www/html volume so the image is completely independent. Additionally, don’t forget to enable Opcache. Copying an opcache.ini file into the Docker PHP configuration directory can help increase the app’s response speed by 20-30%.

Conclusion

Deploying Laravel using a service-separated model makes your system run much smoother and easier to maintain. If the number of jobs spikes in the future, you just need to scale the worker container individually without affecting the web server.

If you encounter a 502 Bad Gateway error, check the logs immediately using the command docker logs laravel_app. Usually, the error lies in file write permissions or the internal network configuration between containers. Good luck with your Dockerization!

Share: