Docker for PHP Developers: From Development to Production
You just onboarded a new developer. They spend two days installing PHP, MySQL, Redis, configuring Nginx, chasing down missing extensions, and fighting version mismatches. Meanwhile your staging server runs PHP 8.2 while production runs 8.3, and nobody noticed until a readonly property crashed in staging. Sound familiar?
Docker eliminates this entire class of problems. One command, identical environments everywhere. Here is exactly how to set it up for PHP/Laravel -- from a working local dev stack to a production-hardened deployment.
Why Docker Matters for PHP Developers
Three concrete reasons:
Environment parity. Your local machine, CI runner, staging server, and production host all execute the same image. If it passes tests in the container, it works in production. No more debugging environment differences.
Onboarding speed. A new developer clones the repo, runs docker compose up -d, and has a fully working stack in under two minutes. No install guides. No tribal knowledge.
CI/CD simplicity. Your pipeline builds the same Docker image that runs in production. No separate provisioning scripts, no Ansible playbooks for dependencies. Build once, deploy the artifact everywhere.
Development Dockerfile
Start with a Dockerfile optimized for development. You want all debugging tools, Xdebug capability, and a non-root user to avoid file permission nightmares.
FROM php:8.3-fpm-alpine AS base
# Install system dependencies
RUN apk add --no-cache \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
libzip-dev \
icu-dev \
linux-headers \
oniguruma-dev \
curl
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql \
zip \
gd \
bcmath \
intl \
opcache \
pcntl \
mbstring
# Install Redis extension
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del .build-deps
# Install Composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Create non-root user matching host UID
ARG UID=1000
ARG GID=1000
RUN addgroup -g ${GID} appuser \
&& adduser -u ${UID} -G appuser -s /bin/sh -D appuser
WORKDIR /var/www/html
# Set ownership
RUN chown -R appuser:appuser /var/www/html
USER appuser
EXPOSE 9000
CMD ["php-fpm"]
Key decisions here: the ARG UID/GID lets you match your host user's ID at build time (docker compose build --build-arg UID=$(id -u)), which prevents files created inside the container from being owned by root on your host. The extensions list covers what 90% of Laravel apps need -- pdo_mysql, gd, bcmath, intl, opcache, pcntl (for Horizon/queue workers), redis, and zip.
Multi-Stage Production Dockerfile
Production images should be small, contain no dev dependencies, and bake in all application code. Multi-stage builds achieve this.
# Stage 1: Install Composer dependencies
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-interaction \
--no-scripts \
--prefer-dist \
--optimize-autoloader
# Stage 2: Build frontend assets
FROM node:20-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json vite.config.js ./
RUN npm ci
COPY resources ./resources
COPY tailwind.config.js postcss.config.js ./
RUN npm run build
# Stage 3: Production runtime
FROM php:8.3-fpm-alpine
RUN apk add --no-cache \
libpng \
libjpeg-turbo \
freetype \
libzip \
icu-libs \
oniguruma \
curl
RUN apk add --no-cache --virtual .build-deps \
libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev \
icu-dev oniguruma-dev $PHPIZE_DEPS \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
pdo_mysql zip gd bcmath intl opcache pcntl mbstring \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& apk del .build-deps
# Production PHP config
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
COPY docker/php/php-fpm-prod.conf /usr/local/etc/php-fpm.d/zz-prod.conf
# Non-root user
RUN addgroup -g 1000 appuser \
&& adduser -u 1000 -G appuser -s /bin/sh -D appuser
WORKDIR /var/www/html
# Copy application code
COPY --chown=appuser:appuser . .
COPY --chown=appuser:appuser --from=vendor /app/vendor ./vendor
COPY --chown=appuser:appuser --from=assets /app/public/build ./public/build
# Cache Laravel bootstrap files
RUN php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache
# Ensure storage is writable
RUN chown -R appuser:appuser storage bootstrap/cache
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:9000/ping || exit 1
USER appuser
EXPOSE 9000
CMD ["php-fpm"]
Three stages: vendor installs Composer dependencies, assets builds frontend with Vite, and the final stage copies only what's needed into a clean runtime image. No Node.js, no Composer, no build tools in the final image.
Docker Compose for Local Development
A complete docker-compose.yml with proper health checks and dependency ordering:
services:
app:
build:
context: .
dockerfile: Dockerfile
target: base
args:
UID: 1000
GID: 1000
volumes:
- .:/var/www/html
- vendor:/var/www/html/vendor
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
environment:
- DB_HOST=mysql
- DB_DATABASE=laravel
- DB_USERNAME=laravel
- DB_PASSWORD=secret
- REDIS_HOST=redis
- CACHE_STORE=redis
- SESSION_DRIVER=redis
networks:
- app-network
nginx:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- .:/var/www/html:ro
depends_on:
- app
networks:
- app-network
mysql:
image: mysql:8.4
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: rootsecret
MYSQL_DATABASE: laravel
MYSQL_USER: laravel
MYSQL_PASSWORD: secret
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--silent"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- app-network
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
volumes:
- redis_data:/data
networks:
- app-network
volumes:
mysql_data:
redis_data:
vendor:
networks:
app-network:
driver: bridge
Notice target: base in the app service -- this builds only the development stage from the Dockerfile, not the full production build. The vendor named volume prevents your host vendor/ from overwriting the container's installed dependencies while the bind mount keeps your code synced.
Health checks on MySQL and Redis with condition: service_healthy mean the PHP container waits until the databases are actually ready, not just started.
Nginx Configuration for Laravel
Place this at docker/nginx/default.conf:
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Max upload size
client_max_body_size 50M;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
# Block dotfiles except .well-known
location ~ /\.(?!well-known) {
deny all;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
fastcgi_read_timeout 300;
fastcgi_buffering on;
fastcgi_buffer_size 16k;
fastcgi_buffers 16 16k;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
}
The fastcgi_pass app:9000 references the service name from docker-compose.yml. Docker's internal DNS resolves it. The $realpath_root directive handles symlinks correctly, which matters if you use Laravel's storage links. fastcgi_hide_header X-Powered-By strips the PHP version from responses -- no reason to advertise it.
The .dockerignore File
Without a .dockerignore, Docker sends your entire context (including vendor/, node_modules/, .git/) to the daemon on every build. This slows builds dramatically and can leak secrets.
.git
.github
.env
.env.*
!.env.example
node_modules
vendor
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
tests
docker-compose*.yml
.editorconfig
.styleci.yml
phpunit.xml
*.md
.idea
.vscode
The vendor exclusion is intentional -- the production Dockerfile installs dependencies via the vendor stage with --no-dev. Including the host's vendor (which likely has dev dependencies) would defeat the purpose. Same logic for node_modules.
Health Checks
Docker health checks let orchestrators (Compose, Swarm, Kubernetes) know when your container is actually ready to serve traffic, not just running.
For PHP-FPM, enable the ping endpoint in your FPM config (docker/php/php-fpm-prod.conf):
[www]
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 1000
ping.path = /ping
ping.response = pong
Then in your Dockerfile:
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -f http://localhost:9000/ping || exit 1
Laravel 12 ships with a /up health endpoint. Use it in your Nginx health check or load balancer to verify the full application stack (database connections, cache, etc.) is functional, not just that PHP-FPM is responding.
Environment Variables and Secrets
Never bake .env files into images. Use environment variables at runtime.
For local development, use an env_file directive:
services:
app:
env_file:
- .env
For production, inject secrets through your orchestrator. With Docker Swarm:
services:
app:
secrets:
- db_password
- app_key
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
db_password:
external: true
app_key:
external: true
If you deploy to a managed platform (AWS ECS, Google Cloud Run, Fly.io), use their native secrets management. The principle is the same: secrets come from the environment at runtime, never from the image.
Production Compose vs Development
Production compose files differ in critical ways. Here is a docker-compose.prod.yml:
services:
app:
image: registry.example.com/myapp:${IMAGE_TAG}
restart: always
environment:
APP_ENV: production
APP_DEBUG: "false"
LOG_CHANNEL: stderr
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
nginx:
image: nginx:1.27-alpine
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./docker/nginx/production.conf:/etc/nginx/conf.d/default.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
- app
mysql:
image: mysql:8.4
restart: always
volumes:
- mysql_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password
redis:
image: redis:7-alpine
restart: always
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
volumes:
mysql_data:
redis_data:
The differences: no bind-mounted source code (the code is baked into the image), pinned image tags instead of building from source, restart: always for crash recovery, resource limits, and LOG_CHANNEL: stderr so Docker captures log output. Redis has a password. MySQL uses a secret file for the root password.
Common Pitfalls
File Permissions
The number one issue. PHP-FPM runs as www-data (UID 82 on Alpine) by default. If your app files are owned by root, Laravel cannot write to storage/ or bootstrap/cache/. Two solutions:
- Match the container user to your host user (the
ARG UIDapproach shown above). - In production, ensure the final
COPY --chownsets correct ownership.
OPcache Settings
OPcache dramatically improves PHP performance but needs different settings per environment. Create docker/php/opcache.ini:
[opcache]
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.jit=tracing
opcache.jit_buffer_size=128M
validate_timestamps=0 is critical for production -- it tells OPcache to never check if files changed, which is safe because your code is immutable inside the image. For development, override this to 1 so code changes take effect immediately.
PHP-FPM Tuning
The default pm = dynamic settings are too conservative. Each PHP-FPM child consumes roughly 30-50MB of RAM. Calculate pm.max_children based on available memory:
max_children = (available_memory - memory_for_other_services) / avg_memory_per_child
For a container with 512MB limit: (512 - 50) / 40 = ~11 children. Set pm.max_requests = 1000 to recycle workers and prevent memory leaks from accumulating.
Slow Builds
Layer ordering matters. Put instructions that change rarely (system packages, extensions) before those that change often (application code). Composer dependencies change less frequently than source code, so copy composer.json and composer.lock first, run composer install, then copy the rest. Docker caches unchanged layers.
Takeaways
- Use multi-stage builds. Keep dev tools out of production images. Final images should be under 150MB.
- Never run as root. Create a dedicated user in every Dockerfile. Match UIDs for development.
- Health checks are not optional. Both Docker-level (
HEALTHCHECK) and application-level (/up) health checks ensure reliable deployments. - Separate dev and prod compose files. Development gets bind mounts and debug tools. Production gets baked-in code, restart policies, and resource limits.
- OPcache with
validate_timestamps=0in production. This single setting can double your throughput. - Keep secrets out of images. Use environment variables, secret files, or your platform's secrets manager. Never commit
.envor bake it into a build.
Start with the development setup, get your team running on it, then build out the production pipeline. The investment pays for itself on the first deployment that just works.