← back to blog
2026-01-10 docker

Docker for PHP Developers: From Local Dev to Production

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:

ServiceImagePurpose
Nginxnginx:alpineWeb server, reverse proxy
PHP-FPMCustom buildPHP processing
MySQLmysql:8Primary database
Redisredis:alpineCache, 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:

  1. File permissions — Containers run as www-data but mounted files are owned by your host user. Use USER www-data in the Dockerfile and fix permissions in the entrypoint.

  2. Slow file I/O on macOS — Docker Desktop’s file sharing is slow. Use :cached or :delegated flags on volume mounts, or use Mutagen for syncing.

  3. Database not ready — PHP starts before MySQL is accepting connections. Always use depends_on with condition: service_healthy.

  4. Running Composer as root — Composer warns against it. Set USER www-data before running Composer commands.

  5. Forgetting to persist data — Without named volumes, docker compose down deletes 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.