Laravel is one of the most popular PHP frameworks, and containerizing a Laravel application has slightly different characteristics compared to a Node.js or Go application, because PHP traditionally requires two components running separately: a web server that receives HTTP requests, and a PHP process manager that executes the PHP code. In addition, a production-ready Laravel application generally requires not just one container to serve HTTP, but also separate containers for background queue workers and schedulers. This chapter covers a full case study: from constructing a Dockerfile for PHP-FPM and configuring Nginx as a reverse proxy, connecting the application to a MySQL or PostgreSQL database, running queue workers and schedulers in separate containers, to managing environment configuration and relevant artisan commands for both development and production.
29.1 Containerizing Laravel with PHP-FPM and Nginx
Unlike Node.js or Go applications that have a built-in HTTP server, PHP-FPM is only responsible for executing PHP scripts and cannot directly accept HTTP requests from a browser. Therefore, containerizing Laravel requires a combination of two images: one for PHP-FPM running the application code, and another for Nginx forwarding requests to PHP-FPM via the FastCGI protocol.
29.1.1 Project Structure and Container Requirements
The case study in this chapter uses a Laravel project with the default directory structure provided by laravel new, along with several Docker configuration files.
myapp/
├── app/
├── bootstrap/
├── config/
├── database/
├── public/
│ └── index.php
├── routes/
├── storage/
├── docker/
│ ├── php/
│ │ └── php.ini
│ ├── nginx/
│ │ └── default.conf
│ └── supervisor/
│ └── supervisord.conf
├── composer.json
├── composer.lock
├── artisan
├── .env.example
└── DockerfileThe public folder contains index.php as the application entry point, and this folder will be set as the document root for Nginx. The docker folder holds additional configuration files required by the images, separated from the application source code for easier management.
29.1.2 Multi-Stage Dockerfile for PHP-FPM
Installing PHP dependencies via Composer and building the application should be separated from the final image, following the multi-stage build pattern used in other application containerization case studies. The following Dockerfile uses the official composer image to install dependencies, followed by the official php base image with the fpm-alpine variant to run the application.
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist \
--no-interaction
COPY . .
RUN composer dump-autoload --optimize --no-dev
FROM php:8.3-fpm-alpine AS production
WORKDIR /var/www/html
RUN apk add --no-cache \
libpng-dev \
libzip-dev \
oniguruma-dev \
&& docker-php-ext-install pdo_mysql mbstring zip gd bcmath
COPY docker/php/php.ini /usr/local/etc/php/conf.d/laravel.ini
COPY --from=vendor /app /var/www/html
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
EXPOSE 9000
CMD ["php-fpm"]The --no-scripts and --no-autoloader options during the composer install step are intentionally used so that the new autoload file is created only after the entire application source code has been copied via COPY . ., as some Laravel packages have post-install scripts that require the full application files, not just composer.json. The docker-php-ext-install instruction is used to enable PHP extensions required by Laravel, such as pdo_mysql for MySQL database connections, following the official mechanism provided by the PHP base image to enable extensions without compiling manually from scratch. For the gd extension specifically, installing via docker-php-ext-install only enables basic PNG support. If the application needs to process JPEG or WebP formats, add docker-php-ext-configure gd --with-jpeg --with-webp before docker-php-ext-install gd so that the supporting libraries are compiled as well.
Ownership of the storage and bootstrap/cache directories is transferred to the www-data user because Laravel writes log, cache, and session files to both folders at runtime. If ownership of these folders remains with root while the PHP-FPM process runs as www-data, the application will fail to write files and throw a Permission denied error upon receiving incoming requests.
The file docker/php/php.ini copied to /usr/local/etc/php/conf.d/laravel.ini contains overrides for default PHP settings that are otherwise too small for general web application needs, such as file upload size limits and request memory limits.
upload_max_filesize = 20M
post_max_size = 20M
memory_limit = 256M
max_execution_time = 60The official PHP base image automatically loads all .ini files in the conf.d directory when the PHP process starts, so these additional configuration files do not need to be manually registered elsewhere. Verify active values using php -i inside the container.
docker compose exec app php -i | grep upload_max_filesize29.1.3 Nginx Configuration as Reverse Proxy
Nginx receives incoming HTTP requests, serves static files directly, and forwards PHP file requests to the PHP-FPM container via FastCGI. Create the following docker/nginx/default.conf file.
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}The line fastcgi_pass app:9000; points to the app service name on port 9000, the default port where PHP-FPM listens for FastCGI connections. This app name corresponds to the PHP-FPM service name defined in compose.yaml, as Docker Compose provides inter-service DNS resolution based on service names. The block location ~ /\.(?!well-known).* blocks direct HTTP access to hidden files like .env, a essential security measure preventing attackers from accessing sensitive configuration files if accidentally stored inside public.
Define the app and web services in a single compose.yaml file, sharing application code via a named volume.
services:
app:
build:
context: .
target: production
volumes:
- app-storage:/var/www/html/storage
environment:
APP_ENV: production
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
volumes:
app-storage:Notice that the web service does not copy application source code at all; it only requires the Nginx configuration file. This differs from mounting source code across both containers via shared bind mounts, a common practice for development but less suitable for production as it makes both images dependent on host folder contents.
29.1.4 Build and Verification
Build both services and run them in detached mode.
docker compose up -d --buildAccess the application using the mapped port on the web service.
curl -I http://localhost:8080If a 502 Bad Gateway response occurs, the most common reason is that Nginx cannot reach PHP-FPM, usually because the service name in fastcgi_pass does not match the PHP-FPM service name in compose.yaml, or the app container has not finished starting when web attempts to forward the initial request. Inspect logs for both services to determine the cause.
docker compose logs app
docker compose logs webA 500 Internal Server Error appearing after a successful FastCGI connection usually originates from Laravel itself, such as an ungenerated application key or unwritable storage folder. Access the app container to examine the Laravel log directly.
docker compose exec app tail -n 50 storage/logs/laravel.log29.2 Setting Up MySQL/PostgreSQL for Laravel
Laravel supports multiple database drivers through Eloquent abstractions and query builder, but MySQL and PostgreSQL remain the two most common choices for production. This section demonstrates how to connect a Laravel container to both databases using Docker Compose.
29.2.1 Database Service in Docker Compose
Add a db service using the official mysql image to the existing compose.yaml file.
services:
app:
build:
context: .
target: production
volumes:
- app-storage:/var/www/html/storage
environment:
APP_ENV: production
DB_CONNECTION: mysql
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: myapp
DB_USERNAME: myapp
DB_PASSWORD: secret
depends_on:
db:
condition: service_healthy
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: myapp
MYSQL_USER: myapp
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: rootsecret
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "myapp", "-psecret"]
interval: 5s
timeout: 3s
retries: 10
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
volumes:
app-storage:
db-data:When choosing PostgreSQL instead, replace the mysql:8.4 image with postgres:16-alpine, adjust environment variables to POSTGRES_DB, POSTGRES_USER, and POSTGRES_PASSWORD, and change DB_CONNECTION on the app service to pgsql with DB_PORT: 5432. Using depends_on with condition: service_healthy ensures Laravel connects only after the database is ready to accept connections, rather than right after the database container starts, as initial startup for MySQL or PostgreSQL requires extra initialization time before accepting connections.
Note that the password in the -p flag of the mysqladmin command for health checks appears as plain text when viewed via docker inspect or running container processes. For production environments with stricter security requirements, Sysadmins or DevOps Engineers should use orchestrator-native secret management mechanisms rather than hardcoding passwords directly in health checks like this example.
29.2.2 Database Connection via .env and config/database.php
Laravel reads database credentials from environment variables rather than directly from config/database.php. That configuration file acts as a bridge calling the env() function to retrieve environment values.
'mysql' => [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
],The DB_HOST value uses the db service name instead of 127.0.0.1 or localhost because inside a Docker Compose network, 127.0.0.1 resolves to the container itself, not the separate database container. This error is a common trap for developers containerizing Laravel for the first time, particularly if previously accustomed to running MySQL directly on local machines via localhost.
Verify database connection success using artisan tinker or the db:show command available since Laravel 9.24.
docker compose exec app php artisan db:showIf an error such as SQLSTATE[HY000] [2002] Connection refused appears, check whether DB_HOST correctly targets the database service name, and confirm the database container shows healthy status via docker compose ps before the application attempts to connect.
29.2.3 Migrations and Seeders Inside Containers
Execute migrations via docker compose exec so artisan commands run inside the app container using the same environment and database connection as the running application.
docker compose exec app php artisan migrateFor initial data seeding, execute the db:seed command separately or combine it with migration using the --seed flag.
docker compose exec app php artisan migrate --seedRunning migrate without additional flags is safe to execute repeatedly because Laravel tracks completed migrations in the migrations table. Unlike migrate:fresh, which drops all tables before running migrations from scratch, this command is destructive and should only be used in development or testing environments, as existing data will be permanently lost.
29.3 Queue Worker and Scheduler in Containers
Laravel applications executing heavy processes like sending emails or processing files asynchronously use queues, which require continuous background worker processes to pick up and process jobs. This process is distinct from PHP-FPM handling HTTP requests, necessitating separate container handling.
29.3.1 Separate Container for Queue Worker
Adhering to the single main process per container philosophy central to Docker, the simplest way to run a queue worker is via a separate service sharing the same image as app, but using a different command.
services:
app:
build:
context: .
target: production
volumes:
- app-storage:/var/www/html/storage
environment:
APP_ENV: production
DB_CONNECTION: mysql
DB_HOST: db
depends_on:
db:
condition: service_healthy
queue:
build:
context: .
target: production
command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600
volumes:
- app-storage:/var/www/html/storage
environment:
APP_ENV: production
DB_CONNECTION: mysql
DB_HOST: db
depends_on:
db:
condition: service_healthy
restart: unless-stoppedThe restart: unless-stopped option replaces Supervisor for keeping the queue:work process running, as the Docker daemon automatically restarts the container if the process stops due to unhandled errors. The --max-time=3600 flag causes workers to exit gracefully after running for one hour, a practice recommended by official Laravel documentation to prevent gradual memory leaks from long-running PHP processes, with Docker restart policies spawning fresh processes immediately upon exit.
The --tries=3 flag limits failed job retries to three attempts before moving the job to the failed jobs table, preventing failing jobs from looping infinitely and consuming worker resources.
29.3.2 Supervisor for Multiple Worker Processes
If an application requires multiple workers running simultaneously within a single container, such as handling different queue connections or increasing job processing throughput, Supervisor is a standard choice for managing multiple child processes and automatically restarting dead ones, per official Laravel recommendations for production queue management. Create the following docker/supervisor/supervisord.conf file.
[supervisord]
nodaemon=true
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=4
user=www-data
stdout_logfile=/var/www/html/storage/logs/worker.log
stopwaitsecs=3600The numprocs=4 directive instructs Supervisor to launch four queue:work processes simultaneously, each monitored and automatically restarted via autorestart=true upon unexpected exits. The stopwaitsecs=3600 directive gives workers time to finish active jobs before forced termination, essential for longer-running tasks.
Create a dedicated image for worker containers using Supervisor as the primary entry process.
FROM php:8.3-fpm-alpine AS worker
WORKDIR /var/www/html
RUN apk add --no-cache supervisor \
libpng-dev libzip-dev oniguruma-dev \
&& docker-php-ext-install pdo_mysql mbstring zip gd bcmath
COPY --from=vendor /app /var/www/html
COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]Verify that all workers are running using supervisorctl inside the container.
docker compose exec queue supervisorctl statusA RUNNING status across all four laravel-worker processes confirms successful initialization by Supervisor. If any process enters a FATAL state repeatedly, the issue typically stems from incorrect command paths or missing database environment variables within the container.
29.3.3 Scheduler with Cron or schedule:work
In addition to queues, Laravel provides task scheduling mechanisms for executing specific commands periodically, such as clearing old data or generating daily reports. Traditionally, this scheduler requires a system cron entry calling php artisan schedule:run every minute, leaving Laravel to determine which tasks require execution based on code definitions.
* * * * * php /var/www/html/artisan schedule:run >> /dev/null 2>&1Running a cron daemon inside a container adds complexity, requiring Supervisor or similar init systems to manage cron and PHP-FPM in tandem, conflicting with the single main process per container model. A better alternative for container environments is the schedule:work command, which according to official Laravel documentation runs in the foreground and triggers the scheduler every minute until stopped, eliminating cron requirements.
services:
scheduler:
build:
context: .
target: production
command: php artisan schedule:work
volumes:
- app-storage:/var/www/html/storage
environment:
APP_ENV: production
DB_CONNECTION: mysql
DB_HOST: db
depends_on:
db:
condition: service_healthy
restart: unless-stoppedThis pattern provides the scheduler container with a clear single process, matching the earlier queue container setup, while Docker restart policies maintain process uptime following crashes. Verify scheduler operation through container logs.
docker compose logs -f schedulerIn practice, a common pitfall encountered by Sysadmins or DevOps Engineers is running multiple scheduler containers concurrently when scaling applications to multiple replicas. Tasks defined without withoutOverlapping() or onOneServer() risk redundant execution by every active scheduler replica, making it best to maintain exactly one scheduler instance regardless of how many app service replicas are scaled.
29.4 Environment Configuration and Artisan Commands
Development-friendly Laravel configurations using standard .env files require adjustments before deployment to production, particularly around injecting environment variables into containers and performing cache steps following configuration changes.
29.4.1 Managing .env Across Environments
The .env file should not be copied into images via Dockerfile COPY instructions, as contents differ across environments and often contain secrets like database passwords or API keys. Add .env to the .dockerignore file to prevent inclusion in the build context.
.env
.git
node_modules
vendor
testsDocker Compose offers the env_file option to load environment variables from external files into containers without embedding them in images.
services:
app:
build:
context: .
target: production
env_file:
- .env.production
volumes:
- app-storage:/var/www/html/storageThis approach maintains a strict boundary between immutable images usable in any environment and environment-specific configurations, reflecting the build once, run anywhere principle of containerization. For highly sensitive credentials like DB_PASSWORD or APP_KEY in production, utilize orchestrator secret management rather than plain text entries in .env.production on the server.
29.4.2 Running Artisan Commands in Containers
All standard artisan commands used locally remain available inside containers via docker compose exec while the target container is active.
docker compose exec app php artisan --version
docker compose exec app php artisan make:controller PostController
docker compose exec app php artisan storage:linkThe storage:link command creates a symbolic link from public/storage to storage/app/public, required at least once for files uploaded to the public filesystem disk to be accessible via public URLs. One-off deployment commands like migrate or storage:link should be executed as distinct pipeline steps rather than container startup scripts, as running automated migrations across multiple replicas simultaneously can introduce database schema race conditions.
29.4.3 Caching Config, Route, and View for Production
By default, Laravel reads configuration files and registers routes on every incoming request, overhead that can be eliminated in production environments. Execute the optimize command to compile all caching steps at once.
docker compose exec app php artisan optimizeThis command executes config:cache, route:cache, view:cache, and event:cache simultaneously per official documentation. Compiled caches remain static until configuration files, routes, or environment variables change, requiring re-execution during deployments rather than a single invocation during image build time.
A common mistake is running config:cache without realizing it freezes env() values into a single cached file. Once config:cache runs, direct env() calls outside config/*.php files (such as in routes or controllers) no longer return active environment variable values, as Laravel resolves entries exclusively from cache. Ensure all environment variable access is routed through configuration files within the config directory rather than inline env() calls to preserve consistent behavior across cached states.
If configuration updates do not reflect in application behavior following deployment, clear existing caches prior to re-caching.
docker compose exec app php artisan optimize:clear
docker compose exec app php artisan optimizeConfirm active caching by verifying the presence of bootstrap/cache/config.php inside the container.
docker compose exec app ls -la bootstrap/cache/
