Docker for PHP Applications: A Complete Guide
“It works on my machine” is the most expensive sentence in software engineering. I said it. My teammate said it. Production said nothing — it just returned 500s because his PHP had mcrypt and mine didn’t.
I resisted Docker for months. Containers felt like extra complexity for a problem that “proper documentation” should solve. Then I spent a full day onboarding a new developer through MAMP configuration, extension mismatches, and a MySQL version that handled strict mode differently than staging. Docker fixed that afternoon. Not eventually. That afternoon.
Here’s the PHP + Docker setup that became our team default — nginx, PHP-FPM, MySQL, and the workflows that survived past the honeymoon phase.
Why Docker for PHP?
Traditional PHP development accumulates pain quietly:
- PHP 5.6 on one machine, 7.0 on another, 7.1 in staging
- Extensions compiled differently (or not at all)
- LAMP/LEMP stacks that take a day to configure correctly
- “Just apt-get install it” advice that breaks on macOS
Docker doesn’t eliminate complexity — it moves it into a file you can version control. docker-compose up and everyone runs the same stack. That’s the whole pitch, and it delivers.
Basic PHP Docker Setup
A sensible project structure:
my-php-app/
├── docker-compose.yml
├── docker/
│ ├── nginx/
│ │ └── default.conf
│ └── php/
│ └── Dockerfile
├── public/
│ └── index.php
└── src/
PHP Dockerfile
# docker/php/Dockerfile
FROM php:7.0-fpm
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libonig-dev \
libxml2-dev \
zip \
unzip
# Clear cache
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
# Get latest Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www
# Copy application files
COPY . /var/www
# Install dependencies
RUN composer install --no-interaction --optimize-autoloader
# Set permissions
RUN chown -R www-data:www-data /var/www
EXPOSE 9000
CMD ["php-fpm"]
Pin your base image tag. php:7.0 today is not php:7.0 six months from now.
Nginx Configuration
# docker/nginx/default.conf
server {
listen 80;
index index.php index.html;
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
root /var/www/public;
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass php:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_name;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
gzip_static on;
}
}
Nginx and PHP-FPM in separate containers is the pattern. One process per container isn’t dogma for every case, but nginx + php-fpm genuinely are different services.
Docker Compose Configuration
# docker-compose.yml
version: '3'
services:
nginx:
image: nginx:alpine
container_name: my-app-nginx
restart: unless-stopped
ports:
- "8000:80"
volumes:
- ./:/var/www
- ./docker/nginx:/etc/nginx/conf.d
networks:
- app-network
depends_on:
- php
php:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: my-app-php
restart: unless-stopped
volumes:
- ./:/var/www
networks:
- app-network
depends_on:
- mysql
mysql:
image: mysql:5.7
container_name: my-app-mysql
restart: unless-stopped
environment:
MYSQL_DATABASE: myapp
MYSQL_ROOT_PASSWORD: secret
MYSQL_PASSWORD: secret
MYSQL_USER: myapp
volumes:
- mysql-data:/var/lib/mysql
ports:
- "3306:3306"
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
mysql-data:
driver: local
depends_on means start order, not “MySQL is ready to accept connections.” More on that later.
Laravel Docker Setup
Laravel needs a few more pieces — Redis, proper permissions on storage/, and PHP extensions for typical packages:
# docker/php/Dockerfile for Laravel
FROM php:7.0-fpm
# Install dependencies
RUN apt-get update && apt-get install -y \
build-essential \
libpng-dev \
libjpeg62-turbo-dev \
libfreetype6-dev \
locales \
zip \
jpegoptim optipng pngquant gifsicle \
vim \
unzip \
git \
curl
# Install extensions
RUN docker-php-ext-install pdo_mysql mbstring zip exif pcntl
RUN docker-php-ext-configure gd --with-gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ --with-png-dir=/usr/include/
RUN docker-php-ext-install gd
# Install composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Add user for laravel application
RUN groupadd -g 1000 www
RUN useradd -u 1000 -ms /bin/bash -g www www
# Copy existing application directory
COPY . /var/www
COPY --chown=www:www . /var/www
# Change current user to www
USER www
# Expose port 9000 and start php-fpm server
EXPOSE 9000
CMD ["php-fpm"]
Complete Laravel docker-compose:
# docker-compose.yml for Laravel
version: '3'
services:
app:
build:
context: .
dockerfile: docker/php/Dockerfile
image: laravel-app
container_name: laravel-app
restart: unless-stopped
tty: true
environment:
SERVICE_NAME: app
SERVICE_TAGS: dev
working_dir: /var/www
volumes:
- ./:/var/www
- ./docker/php/local.ini:/usr/local/etc/php/conf.d/local.ini
networks:
- app-network
webserver:
image: nginx:alpine
container_name: laravel-webserver
restart: unless-stopped
tty: true
ports:
- "80:80"
- "443:443"
volumes:
- ./:/var/www
- ./docker/nginx/conf.d/:/etc/nginx/conf.d/
networks:
- app-network
db:
image: mysql:5.7.22
container_name: laravel-db
restart: unless-stopped
tty: true
ports:
- "3306:3306"
environment:
MYSQL_DATABASE: laravel
MYSQL_ROOT_PASSWORD: secret
SERVICE_TAGS: dev
SERVICE_NAME: mysql
volumes:
- dbdata:/var/lib/mysql
- ./docker/mysql/my.cnf:/etc/mysql/my.cnf
networks:
- app-network
redis:
image: redis:alpine
container_name: laravel-redis
restart: unless-stopped
ports:
- "6379:6379"
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
dbdata:
driver: local
Development Workflow
The commands you’ll run daily:
# Build and start containers
docker-compose up -d --build
# View logs
docker-compose logs -f
# Stop containers
docker-compose down
# Stop and remove volumes
docker-compose down -v
Running application commands inside containers:
# Execute PHP commands
docker-compose exec php php artisan migrate
# Install Composer dependencies
docker-compose exec php composer install
# Run tests
docker-compose exec php vendor/bin/phpunit
# Access PHP container
docker-compose exec php bash
# MySQL commands
docker-compose exec mysql mysql -u root -p
The pattern: your code lives on the host, containers provide the runtime. Edit locally, execute in container.
Multi-Stage Builds for Production
Development images are fat. Production images should contain only what’s needed to run:
# Build stage
FROM composer:latest AS build
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts
# Production stage
FROM php:7.0-fpm-alpine
# Install runtime dependencies only
RUN apk add --no-cache \
mysql-client \
&& docker-php-ext-install pdo_mysql opcache
# Copy built dependencies
COPY --from=build /app/vendor /var/www/vendor
# Copy application
COPY . /var/www
# Optimize for production
RUN chown -R www-data:www-data /var/www
RUN php artisan config:cache
RUN php artisan route:cache
WORKDIR /var/www
EXPOSE 9000
CMD ["php-fpm"]
Smaller images deploy faster and expose less attack surface.
Performance Optimization
PHP-FPM Configuration
; docker/php/www.conf
[www]
user = www-data
group = www-data
listen = 9000
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 35
pm.max_requests = 500
; Performance
request_terminate_timeout = 30s
catch_workers_output = yes
Tune pm.max_children based on available memory. Each PHP-FPM child consumes RAM; too many children and you OOM the container.
PHP Configuration
; docker/php/local.ini
upload_max_filesize = 40M
post_max_size = 40M
memory_limit = 256M
max_execution_time = 600
max_input_time = 600
; OPcache
opcache.enable = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 8
opcache.max_accelerated_files = 4000
opcache.revalidate_freq = 60
opcache.fast_shutdown = 1
Enable OPcache in production. Running PHP without it is leaving performance on the table.
Docker Compose for Different Environments
Override files keep environment differences explicit:
# docker-compose.dev.yml
version: '3'
services:
php:
environment:
APP_ENV: local
APP_DEBUG: "true"
volumes:
- ./:/var/www:cached # Faster on macOS
# docker-compose.prod.yml
version: '3'
services:
php:
environment:
APP_ENV: production
APP_DEBUG: "false"
# No volumes - use built-in code
# Development
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
# Production
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up
Debugging with Xdebug
Add Xdebug to your development Dockerfile only — never production:
# Install Xdebug
RUN pecl install xdebug-2.4.0 \
&& docker-php-ext-enable xdebug
# Xdebug config
RUN echo "xdebug.remote_enable=1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
&& echo "xdebug.remote_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
&& echo "xdebug.remote_port=9001" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
Step debugging inside containers feels magical the first time it works. It feels frustrating the first three times it doesn’t.
Common Issues and Solutions
Permission Issues
Laravel’s storage/ and bootstrap/cache/ need write access:
# Fix permission issues on Linux
docker-compose exec php chown -R www-data:www-data /var/www/storage
docker-compose exec php chmod -R 775 /var/www/storage
Slow Performance on macOS
Docker on macOS volume mounts are notoriously slow. Use cached mounts:
volumes:
- ./:/var/www:cached
Or sync only specific directories. Your mileage varies by project size.
Container Can’t Connect to MySQL
MySQL takes seconds to initialize. depends_on doesn’t wait for readiness:
# Install wait-for-it script
COPY wait-for-it.sh /usr/local/bin/
# Use it in entrypoint
CMD ["wait-for-it", "mysql:3306", "--", "php-fpm"]
Practices That Actually Help
Use a .dockerignore — don’t send vendor/, node_modules/, and .git to the build context:
.git
.env
vendor/
node_modules/
storage/
*.md
Keep containers single-purpose. Pin image tags. Don’t run as root in production. Add health checks so orchestrators know when a container is actually ready:
healthcheck:
test: ["CMD", "php-fpm-healthcheck"]
interval: 30s
timeout: 3s
retries: 3
Wrapping Up
Docker didn’t just fix “works on my machine.” It made onboarding a git clone && docker-compose up instead of a day of environment archaeology. Deployments became predictable because production ran the same image we tested locally.
Start with a simple docker-compose setup — nginx, PHP-FPM, MySQL. Add Redis, multi-stage builds, and environment overrides as you need them. The initial time investment pays back quickly in fewer environment fires and faster team velocity.
This guide uses Docker 1.13 and PHP 7.0, reflecting the ecosystem in early 2016. Modern Docker Compose, PHP 8.x, and Laravel Sail have improved ergonomics significantly — but the nginx + PHP-FPM separation pattern remains the foundation.