Cloud

Dockerizing PHP Apps: Best Practices for 2026

Build production-ready Docker images for PHP applications. Covers multi-stage builds, php-fpm tuning, health checks, and security hardening.

Docker container architecture for a PHP application stack

Docker has been the standard way to package and deploy PHP applications for years. But the difference between a Dockerfile that works in development and one that runs reliably in production is significant. Most PHP Docker images carry unnecessary weight, miss security hardening, and skip optimizations that matter at scale.

This guide covers building production-ready PHP Docker images in 2026, with concrete examples for Laravel, Symfony, and framework-agnostic PHP.

The minimal production Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# Stage 1: Install dependencies
FROM composer:2 AS deps
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist --optimize-autoloader

# Stage 2: Build production image
FROM php:8.4-fpm-bookworm AS production

# Install system dependencies and PHP extensions
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
libzip-dev \
&& docker-php-ext-install pdo_pgsql zip opcache \
&& apt-get purge -y --auto-remove \
&& rm -rf /var/lib/apt/lists/*

# PHP production configuration
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
COPY docker/php/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf

# Application files
WORKDIR /var/www
COPY --from=deps /app/vendor ./vendor
COPY . .

# Remove files that should not be in production
RUN rm -rf tests/ docker/ .env.example .git/ .github/ \
&& chown -R www-data:www-data storage/ bootstrap/cache/

# Health check
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD php-fpm-healthcheck || exit 1

EXPOSE 9000
USER www-data
CMD ["php-fpm"]

Why multi-stage builds matter

The first stage (deps) installs Composer dependencies. The second stage (production) starts from a clean PHP image and copies only the vendor directory. This ensures:

  • Composer itself is not in the final image
  • Dev dependencies are not included
  • Build tools and caches are discarded
  • The final image is smaller and has fewer attack surfaces

PHP extension installation

Every PHP extension you install adds to the image size and attack surface. Install only what you need.

1
2
3
4
5
6
7
8
9
10
11
12
# Common extensions for a typical PHP application
RUN docker-php-ext-install \
pdo_mysql \
opcache \
intl \
zip \
bcmath \
pcntl

# Extensions requiring PECL
RUN pecl install redis apcu \
&& docker-php-ext-enable redis apcu

Extension size impact

Extension Size added When you need it
pdo_mysql ~2MB MySQL/MariaDB access
pdo_pgsql ~3MB PostgreSQL access
intl ~25MB Internationalization, number/date formatting
gd ~15MB Image manipulation
imagick ~40MB Advanced image processing
redis ~1MB Redis caching
opcache <1MB Always in production

The intl and imagick extensions are the largest. Only include them if your application uses them.

OPcache configuration

OPcache is the single most important performance optimization for production PHP:

1
2
3
4
5
6
7
8
9
10
; 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_buffer_size=100M
opcache.jit=1255

Critical setting: validate_timestamps=0 tells PHP not to check if files have changed. In Docker, files never change after the image is built, so this check is wasted work. It saves measurable CPU on every request.

The JIT settings enable PHP’s Just-In-Time compiler, which benefits CPU-intensive operations (math, string processing, loops).

php-fpm tuning

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
; docker/php/php-fpm.conf
[www]
user = www-data
group = www-data

listen = 0.0.0.0:9000

pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 1000

; Slow log for debugging performance issues
slowlog = /proc/self/fd/2
request_slowlog_timeout = 5s

Calculating max_children

1
max_children = Available RAM / Average memory per PHP process

If your container has 512MB RAM limit and each PHP process uses ~30MB:

1
max_children = 512 / 30 ≈ 15 (leave headroom for OS and Nginx)

Setting max_children too high causes the container to exceed its memory limit and get OOM-killed.

Nginx as a sidecar

PHP-FPM does not serve HTTP directly. You need a web server in front of it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# docker/nginx/default.conf
server {
listen 80;
server_name _;
root /var/www/public;
index index.php;

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 ~* \.(css|js|jpg|jpeg|png|gif|ico|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# docker-compose.yml
services:
app:
build:
context: .
target: production
volumes:
- app-storage:/var/www/storage
environment:
- APP_ENV=production
- DB_HOST=db

nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
app:
condition: service_healthy

db:
image: mysql:8.4
environment:
MYSQL_DATABASE: app
MYSQL_ROOT_PASSWORD: secret
volumes:
- db-data:/var/lib/mysql

volumes:
app-storage:
db-data:

Security hardening

Run as non-root

1
2
# Create and use a non-root user
USER www-data

Never run PHP-FPM as root in production. The www-data user limits the blast radius of a compromised application.

Minimize the image

1
2
3
4
5
6
7
8
# Remove unnecessary files
RUN rm -rf \
/var/www/tests \
/var/www/.git \
/var/www/.env.example \
/var/www/docker \
/var/www/phpunit.xml \
/var/www/.phpstan.neon

Scan for vulnerabilities

1
2
3
# Scan the built image with Trivy
docker build -t myapp:latest .
trivy image myapp:latest

Run this in CI. Fail the build if critical vulnerabilities are found in the base image or installed packages.

Common mistakes

Using latest tag for base images: php:latest may change PHP versions unexpectedly. Always pin to a specific version: php:8.4.2-fpm-bookworm.

Copying .env into the image: Environment variables should come from the runtime environment (Docker secrets, Kubernetes ConfigMaps), not baked into the image.

Not using .dockerignore:

1
2
3
4
5
6
7
8
# .dockerignore
.git
node_modules
tests
docker-compose.yml
.env
.env.*
*.md

Without .dockerignore, Docker sends your entire project directory (including .git and node_modules) to the build context, slowing builds significantly.

Running Composer as root: Composer warns against this for good reason. Use multi-stage builds to avoid the issue entirely.

FAQ

Should I use Alpine or Debian-based PHP images?

Debian-based images are larger (~150MB vs ~50MB) but have better compatibility with PHP extensions and fewer surprise issues with musl libc. Use Alpine if image size is critical and you have tested all your extensions.

How do I handle file uploads in Docker?

Mount a volume for the upload directory. Do not write to the container filesystem—it is ephemeral and not shared across replicas.

Should I include Nginx in the same container as PHP-FPM?

No. Run them as separate containers. This follows the one-process-per-container principle and allows independent scaling.

Next steps

Start with the multi-stage Dockerfile template above and adapt it to your application. Build the image, run docker image inspect to verify the size, and run a security scan with Trivy. The first build takes time to get right, but once the Dockerfile is solid, it rarely needs changes.

For PHP runtime performance inside Docker containers, the FrankenPHP guide covers an alternative to the php-fpm + Nginx pattern. The Swoole guide covers high-concurrency deployments that use a single container instead of the php-fpm + Nginx pair.