I’ve dockerized over a dozen PHP projects — Laravel, Magento, CS-Cart. This is the setup I’ve refined over time, covering local development, debugging, performance tuning, and production deployment.
The Development Stack
Most PHP projects need these four services:
| Service | Image | Purpose |
|---|---|---|
| Nginx | nginx:alpine | Web server, reverse proxy |
| PHP-FPM | Custom build | PHP processing |
| MySQL | mysql:8 | Primary database |
| Redis | redis:alpine | Cache, sessions, queues |
Docker Compose Configuration
Here’s the docker-compose.yml I start every project with:
version: '3.8'
services:
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./src:/var/www/html
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- php
networks:
- app
php:
build:
context: ./docker/php
dockerfile: Dockerfile
volumes:
- ./src:/var/www/html
- ./docker/php/php.ini:/usr/local/etc/php/conf.d/custom.ini
environment:
- DB_HOST=mysql
- DB_DATABASE=app
- DB_USERNAME=root
- DB_PASSWORD=secret
- REDIS_HOST=redis
depends_on:
mysql:
condition: service_healthy
networks:
- app
mysql:
image: mysql:8
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: app
volumes:
- mysql_data:/var/lib/mysql
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 3s
retries: 10
networks:
- app
redis:
image: redis:alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- app
volumes:
mysql_data:
redis_data:
networks:
app:
driver: bridge
Key decisions in this config:
- Health checks on MySQL — PHP won’t crash on startup waiting for the database
- Named volumes — data persists between container restarts
- Custom network — services communicate by name (
mysql,redis) - Volume mounts for config — change Nginx/PHP config without rebuilding
Custom PHP Dockerfile
The default PHP images are minimal. Here’s what I add:
FROM php:8.3-fpm-alpine
# Install system dependencies
RUN apk add --no-cache \
freetype-dev \
libjpeg-turbo-dev \
libpng-dev \
libzip-dev \
icu-dev \
oniguruma-dev \
$PHPIZE_DEPS
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql \
gd \
zip \
intl \
mbstring \
opcache \
pcntl \
bcmath
# Install Redis extension
RUN pecl install redis && docker-php-ext-enable redis
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www/html
# PHP-FPM configuration for performance
RUN echo "pm.max_children = 50" >> /usr/local/etc/php-fpm.d/www.conf \
&& echo "pm.start_servers = 10" >> /usr/local/etc/php-fpm.d/www.conf \
&& echo "pm.min_spare_servers = 5" >> /usr/local/etc/php-fpm.d/www.conf \
&& echo "pm.max_spare_servers = 20" >> /usr/local/etc/php-fpm.d/www.conf
USER www-data
Nginx Configuration
The Nginx config for PHP-FPM:
server {
listen 80;
server_name localhost;
root /var/www/html/public;
index index.php index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 256;
# Static file caching
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# Timeouts for long-running scripts
fastcgi_read_timeout 300;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
}
# Block access to sensitive files
location ~ /\.(env|git|htaccess) {
deny all;
}
}
PHP Configuration for Development
Create docker/php/php.ini:
; Error reporting (development)
display_errors = On
error_reporting = E_ALL
log_errors = On
; Performance
memory_limit = 256M
max_execution_time = 300
upload_max_filesize = 50M
post_max_size = 50M
; OPcache (enable even in dev for speed)
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 10000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0
; Session handling with Redis
session.save_handler = redis
session.save_path = "tcp://redis:6379"
Useful Commands
Commands I run daily:
# Start the stack
docker compose up -d
# Watch logs
docker compose logs -f php
# Run artisan commands (Laravel)
docker compose exec php php artisan migrate
docker compose exec php php artisan queue:work
# Run composer
docker compose exec php composer install
# Access MySQL CLI
docker compose exec mysql mysql -u root -psecret app
# Clear Redis cache
docker compose exec redis redis-cli FLUSHALL
# Rebuild after Dockerfile changes
docker compose build --no-cache php
docker compose up -d
# Check resource usage
docker stats
Production Dockerfile
For production, the Dockerfile changes significantly:
FROM php:8.3-fpm-alpine AS production
RUN apk add --no-cache \
freetype-dev libjpeg-turbo-dev libpng-dev \
libzip-dev icu-dev oniguruma-dev
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql gd zip intl mbstring opcache pcntl bcmath
RUN pecl install redis && docker-php-ext-enable redis
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
# Copy application code
COPY --chown=www-data:www-data ./src .
# Install dependencies without dev packages
RUN composer install --no-dev --optimize-autoloader --no-scripts
# Production PHP config
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini \
&& echo "display_errors=Off" >> /usr/local/etc/php/conf.d/production.ini \
&& echo "log_errors=On" >> /usr/local/etc/php/conf.d/production.ini
USER www-data
The key differences: opcache.validate_timestamps=0 (don’t check file changes), --no-dev composer install, display_errors=Off, and the code is copied into the image instead of mounted.
Common Pitfalls
Mistakes I’ve made so you don’t have to:
-
File permissions — Containers run as
www-databut mounted files are owned by your host user. UseUSER www-datain the Dockerfile and fix permissions in the entrypoint. -
Slow file I/O on macOS — Docker Desktop’s file sharing is slow. Use
:cachedor:delegatedflags on volume mounts, or use Mutagen for syncing. -
Database not ready — PHP starts before MySQL is accepting connections. Always use
depends_onwithcondition: service_healthy. -
Running Composer as root — Composer warns against it. Set
USER www-databefore running Composer commands. -
Forgetting to persist data — Without named volumes,
docker compose downdeletes your database.
This setup has served me across Laravel, Magento, and CS-Cart projects for the past three years. Tweak the PHP extensions and Nginx config for your framework, and you’ll have a solid foundation.