The standard production-shape Docker for a PHP app is php-fpm in one container and Nginx in another, talking via a Docker network. The Dockerfile is straightforward; the bits that catch people are the Composer build step, the OPcache config that doesn't ship enabled, and the Laravel-specific permissions on storage/ and bootstrap/cache/.
A working Dockerfile (Laravel-shaped)
# Composer install (build stage)
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --no-dev
# Runtime
FROM php:8.4-fpm-alpine
RUN docker-php-ext-install pdo_mysql opcache
RUN docker-php-ext-enable opcache
COPY ./docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
WORKDIR /var/www/html
COPY --from=vendor --chown=www-data:www-data /app /var/www/html
RUN chown -R www-data:www-data storage bootstrap/cache && \
chmod -R 775 storage bootstrap/cache
USER www-data
EXPOSE 9000
CMD ["php-fpm"]With a matching nginx.conf (next section) and Compose to wire them together.
Pick the right PHP image
php:8.4-fpm-alpine— PHP-FPM on Alpine. Smallest (~80 MB). The default I reach for.php:8.4-fpm— PHP-FPM on Debian-slim. Bigger but glibc and more extensions install cleanly.php:8.4-apache— PHP + Apache + mod_php in one container. Easier (one container, no Nginx-side config), but less flexible. Use for small apps where the simpler shape is worth more than the configurability.php:8.4-cli— CLI only, for one-off commands and queue workers. See Run a Specific PHP Version in Docker.
Pin to a minor version (8.4); avoid latest.
Install PHP extensions
The base image doesn't ship most extensions. Install with docker-php-ext-install:
RUN docker-php-ext-install pdo_mysql opcache bcmathExtensions that need libraries: install the dev package, install the extension, then remove the dev package:
RUN apk add --no-cache --virtual .build-deps libpng-dev jpeg-dev freetype-dev && \
docker-php-ext-configure gd --with-freetype --with-jpeg && \
docker-php-ext-install gd && \
apk del .build-deps && \
apk add --no-cache libpng jpeg freetypeThe --virtual group lets you remove the build-time packages in one shot while keeping the runtime libraries.
OPcache config
OPcache is bundled with PHP since 5.5 but off by default. Without it, Laravel/Symfony performance is terrible — PHP parses every file on every request. Enable and tune:
docker/opcache.ini:
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0
opcache.revalidate_freq=0validate_timestamps=0 means OPcache trusts its cache and never re-stats files. Critical for production — re-stating thousands of files per request is what kills perf. Do not use this in dev because then file edits don't take effect without a container restart.
Nginx config
Nginx in its own container, talking to PHP-FPM via the Compose network:
docker/nginx.conf:
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php index.html;
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 ~ /\. {
deny all;
}
}fastcgi_pass app:9000 — app is the PHP-FPM service name in the Compose file. Both containers need the same /var/www/html/public mounted (Nginx serves the static files; PHP-FPM runs the PHP).
Compose
services:
app:
build: .
volumes:
- app-storage:/var/www/html/storage
environment:
APP_ENV: production
APP_KEY: ${APP_KEY}
DB_HOST: db
DB_DATABASE: laravel
DB_USERNAME: laravel
DB_PASSWORD: ${DB_PASSWORD}
depends_on:
db:
condition: service_healthy
web:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
db:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: laravel
MYSQL_USER: laravel
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_ROOT_PASSWORD}"]
interval: 5s
timeout: 3s
retries: 5
volumes:
app-storage:
db-data:Critical detail: Nginx and PHP-FPM both need access to the application files. In the Dockerfile, the app image includes the full Laravel codebase; Nginx serves it from /var/www/html/public. For that to work, either:
- Mount the source code into both containers (more common in dev).
- Build the source into the
appimage AND mount that same volume into Nginx.
For the production shape above, the simplest setup is to bake the source into the app image and use a shared volume to expose it to Nginx. The exact mechanics vary by setup; the canonical Laravel-in-Docker community pattern is the laravel-docker template.
Laravel storage and bootstrap/cache
RUN chown -R www-data:www-data storage bootstrap/cache && \
chmod -R 775 storage bootstrap/cacheLaravel writes to storage/ (logs, sessions, uploads, cache files) and bootstrap/cache/ (compiled config, route cache). Without write access for the www-data user, your app boots once and then crashes with permission errors. Set ownership in the Dockerfile.
In Compose, the storage volume keeps user uploads and logs across container restarts:
services:
app:
volumes:
- app-storage:/var/www/html/storageQueue workers and scheduler
PHP-FPM handles web requests. Queue workers and the scheduler run in separate containers using the same image but different commands:
services:
app:
# web (php-fpm) — as before
queue:
build: .
command: php artisan queue:work --tries=3 --timeout=90
environment:
APP_ENV: production
# same as web
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
scheduler:
build: .
command: sh -c "while true; do php artisan schedule:run --verbose --no-interaction; sleep 60; done"
environment:
APP_ENV: production
# same as web
depends_on:
db:
condition: service_healthyEach is a sibling container with the same image but a different command: override.
Common pitfalls
- OPcache off in production. Performance is terrible. Enable it via the
opcache.inirecipe above and setvalidate_timestamps=0. - Storage and bootstrap/cache permissions. Set ownership in the Dockerfile so the
www-datauser can write. - PHP-FPM unable to reach the host. Inside the
appcontainer,localhostis the container itself. To reach the database container, use the service name (db). To reach the host, usehost.docker.internal(Docker Desktop) or set up an explicit host mapping. - Forgot to install extensions. Errors like
could not find drivermeanpdo_mysql(or whichever DB driver) wasn't installed. Add it viadocker-php-ext-install. .envbaked into the image. Don't COPY.env; pass env vars via Compose or--env-fileat runtime.- Apache image + complex Nginx config attempted. The
php:apacheandphp:fpmimages serve different setups. If you want fine-grained Nginx control, start fromphp:fpm-alpineand a separate Nginx container.
What to do next
- Run a Specific PHP Version in Docker — one-off scripts and Composer without an app image.
- How to Run MySQL in Docker and PostgreSQL — DB recipes for the Compose stack.
- How to Run Nginx in Docker — Nginx-specific details.
- Docker Image Size Optimization — going further with multi-stage and Alpine.
FAQ
Sources
Authoritative references this article was fact-checked against.
- Official PHP image — Docker Hubhub.docker.com
- Laravel deployment documentationlaravel.com

