Introduction #
It is 2025. If you are still deploying PHP applications by FTP-ing files to a shared server or manually configuring systemd services on a VPS, it is time for a paradigm shift. The ecosystem has matured significantly. Modern PHP (8.2, 8.3, and beyond) is faster and more robust than ever, but it requires a runtime environment that matches its sophistication.
Containerization is no longer just a buzzword; it is the industry standard for reproducibility, scalability, and security. For mid-to-senior developers, understanding how to build a production-ready Docker image is as crucial as understanding Dependency Injection or Asynchronous programming.
In this deep dive, we aren’t just going to run docker run php. We are going to build a battle-hardened architecture. We will cover multi-stage builds to keep images small, secure user permissions to prevent root exploits, and tune OpCache for maximum throughput.
By the end of this guide, you will have a template that you can take directly to production, whether you are deploying to AWS ECS, Google Cloud Run, or a bare-metal Kubernetes cluster.
Prerequisites and Environment Setup #
Before we dive into the Dockerfile, ensure your development environment is ready. We assume you are working on a machine capable of running containers (Linux, macOS with OrbStack/Docker Desktop, or Windows WSL2).
What You Need #
- Docker Engine & CLI: Version 24.0+.
- Docker Compose: Version 2.20+ (We will use the modern
docker composecommand, not the legacydocker-compose). - PHP 8.2+: Installed locally for IDE intelligence (optional but recommended).
- Composer: For dependency management.
- Terminal: bash, zsh, or PowerShell.
The Application Structure #
We will build a clean directory structure to keep our infrastructure code separate from our application code.
my-php-app/
├── src/
│ ├── public/
│ │ └── index.php
│ └── ... (other app files)
├── docker/
│ ├── nginx/
│ │ └── default.conf
│ └── php/
│ ├── Dockerfile
│ ├── php.ini
│ └── opcache.ini
├── composer.json
├── composer.lock
└── docker-compose.ymlThe Architecture: How It Fits Together #
In a traditional VM setup, you often have Apache/ModPHP or Nginx and PHP-FPM running on the same OS. In Docker, we follow the Single Responsibility Principle. One container does one thing.
- Service A (App): PHP-FPM (FastCGI Process Manager). It handles code execution.
- Service B (Web Server): Nginx. It handles static files and proxies dynamic requests to Service A.
- Service C (Data): MySQL/PostgreSQL (Stateful).
- Service D (Cache): Redis (Ephemeral/Stateful).
Here is how the data flows in our containerized stack:
Step 1: The Application Skeleton #
Let’s create a minimal PHP application to verify our setup. We need a composer.json and a public entry point.
composer.json
{
"name": "phpdevpro/docker-demo",
"description": "A production-ready docker setup",
"type": "project",
"require": {
"php": "^8.2",
"ext-pdo": "*",
"vlucas/phpdotenv": "^5.6"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}src/public/index.php
<?php
require __DIR__ . '/../../vendor/autoload.php';
header('Content-Type: application/json');
echo json_encode([
'status' => 'success',
'message' => 'PHP is running inside Docker!',
'php_version' => PHP_VERSION,
'server_software' => $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown',
'timestamp' => date('c')
]);Run composer install --ignore-platform-reqs locally to generate the lock file, or wait until we build the container.
Step 2: The Multi-Stage Dockerfile #
This is the most critical part of this guide. We will use a Multi-Stage Build.
- Build Stage: Contains generic build tools, git, and unzip. We use this to run Composer and install dependencies.
- Production Stage: A slimmed-down version containing only the runtime requirements. This drastically reduces image size and attack surface.
docker/php/Dockerfile
# ==========================================
# Stage 1: Build (Composer Dependencies)
# ==========================================
FROM php:8.3-fpm-alpine AS deps
# Install system dependencies required for Composer and extensions
# git: for cloning repos
# unzip: for composer extraction
RUN apk add --no-cache git unzip
# Install Composer globally
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
WORKDIR /app
# Copy definition files first to leverage Docker layer caching
COPY composer.json composer.lock ./
# Install dependencies (no dev deps, optimized autoloader)
RUN composer install --no-dev --optimize-autoloader --no-scripts --no-interaction
# ==========================================
# Stage 2: Production Image
# ==========================================
FROM php:8.3-fpm-alpine AS production
# 1. Install System Dependencies (Minimal)
# using docker-php-extension-installer for ease of use
ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN chmod +x /usr/local/bin/install-php-extensions && \
install-php-extensions pdo_mysql opcache redis zip intl bcmath
# 2. Production PHP Configuration
# We copy custom configs (created in next steps)
COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
# 3. User Setup
# Don't run as root! Use the default 'www-data' user provided by the image
WORKDIR /var/www/html
# 4. Copy Code from Build Stage
COPY --from=deps /app/vendor ./vendor
COPY src ./src
COPY src/public ./public
# 5. Permission Fixing
# Ensure www-data owns the application directory
RUN chown -R www-data:www-data /var/www/html
# Switch to non-root user
USER www-data
# Expose port (PHP-FPM defaults to 9000)
EXPOSE 9000
# Start PHP-FPM
CMD ["php-fpm"]Why Alpine Linux? #
There is often a debate between Debian Slim and Alpine. Let’s look at the data:
| Feature | Alpine Linux | Debian Slim | Recommendation |
|---|---|---|---|
| Image Size | Very Small (~5MB base) | Small (~30MB base) | Alpine for microservices |
| C Standard Lib | musl libc | glibc | Debian if using complex extensions (e.g., Oracle OCI, gRPC) |
| Package Manager | apk (fast) | apt (stable) | Alpine for speed |
| Security Scanning | Fewer false positives | More common vulnerabilities | Alpine |
For 95% of standard PHP web applications (Laravel, Symfony, WordPress), Alpine is perfectly stable and significantly lighter.
Step 3: Configuring OpCache for Production #
PHP 8 is fast, but without OpCache, it recompiles scripts on every request. In production, code doesn’t change, so we can cache the bytecode permanently in memory.
docker/php/opcache.ini
[opcache]
opcache.enable=1
; 0 means it never checks the file timestamp.
; Great for perf, but you must restart container to deploy code changes.
opcache.validate_timestamps=0
opcache.revalidate_freq=0
; Memory size (adjust based on your app size)
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
; JIT Compiler (PHP 8 feature)
opcache.jit_buffer_size=100M
opcache.jit=tracingdocker/php/php.ini (General Settings)
[PHP]
memory_limit = 256M
upload_max_filesize = 20M
post_max_size = 20M
expose_php = Off ; Security best practice
display_errors = Off
log_errors = On
error_log = /dev/stderrStep 4: The Nginx Proxy #
PHP-FPM cannot speak HTTP directly to a browser effectively; it speaks the FastCGI protocol. We need Nginx to translate.
docker/nginx/default.conf
server {
listen 80;
server_name localhost;
root /var/www/html/public; # Point to the 'public' folder, not root!
index index.php;
charset utf-8;
# Security headers
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
# Pass PHP scripts to FastCGI server
location ~ \.php$ {
fastcgi_pass app:9000; # 'app' is the service name in docker-compose
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
# Performance tuning
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
}
location ~ /\.(?!well-known).* {
deny all;
}
}Step 5: Orchestration with Docker Compose #
Now we tie it all together. We will define our services, networks, and volumes.
docker-compose.yml
services:
# The PHP Application
app:
build:
context: .
dockerfile: docker/php/Dockerfile
target: production
container_name: php_app
restart: unless-stopped
tty: true
environment:
SERVICE_NAME: app
APP_ENV: ${APP_ENV:-production}
networks:
- php_network
# Use healthchecks to ensure PHP-FPM is actually ready
healthcheck:
test: ["CMD-SHELL", "pgrep php-fpm || exit 1"]
interval: 30s
timeout: 5s
retries: 3
# The Web Server
web:
image: nginx:alpine
container_name: php_web
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./src/public:/var/www/html/public # Sync public files
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
networks:
- php_network
depends_on:
- app
# The Database
db:
image: mysql:8.0
container_name: php_db
restart: unless-stopped
environment:
MYSQL_DATABASE: laravel_db
MYSQL_USER: user
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: root_password
volumes:
- db_data:/var/lib/mysql
networks:
- php_network
command: --default-authentication-plugin=mysql_native_password
networks:
php_network:
driver: bridge
volumes:
db_data:How to Run It #
- Open your terminal.
- Navigate to the project root.
- Run:
docker compose up -d --build - Visit
http://localhost:8080. You should see the JSON response from your PHP script.
Development vs. Production Workflows #
The setup above leans towards production. However, in development, you want code changes to reflect immediately without rebuilding images.
The “Target” Trick #
In our Dockerfile, we used target: production. For local development, you might want to install xdebug and dev dependencies.
You can create a docker-compose.override.yml for local development:
services:
app:
# Bind mount the source code so changes reflect instantly
volumes:
- ./src:/var/www/html/src
- ./src/public:/var/www/html/public
# Override php.ini settings for dev
environment:
PHP_OPCACHE_VALIDATE_TIMESTAMPS: 1 When you run docker compose up, Docker automatically merges the base file with the override file.
Common Pitfalls and Solutions #
1. File Permission Hell #
This is the most common issue. PHP runs as www-data inside the container, but you edit files as your-user on the host. When you bind-mount volumes, permissions can clash.
Solution:
On Linux, ensure your UID matches the container user, or simply allow the container to read files. The chown command in our Dockerfile handles the build stage artifacts. For bind mounts (dev), usually Docker Desktop handles the translation magic. If on raw Linux, you may need to pass your UID:
user: "${UID}:${GID}"2. Networking Issues #
“Connection Refused” between Nginx and PHP.
- Check: Ensure
fastcgi_passin Nginx config matches the service name indocker-compose.yml(e.g.,app), notlocalhost. Containers talk via DNS names.
3. Slow Docker Performance on macOS #
If you use bind mounts on macOS, disk I/O can be slow.
- Solution: Enable VirtioFS in Docker Desktop settings. This provides near-native speeds. Alternatively, use OrbStack, which is rapidly replacing Docker Desktop for many Mac PHP developers due to its speed.
Performance Benchmarking #
A containerized app should not be slower than a bare metal one if configured correctly.
Using ab (Apache Bench) to test our setup:
# 1000 requests, 100 concurrent
ab -n 1000 -c 100 http://localhost:8080/Without OpCache: ~200 Req/Sec (Depending on hardware) With OpCache (JIT enabled): ~800+ Req/Sec
The difference is exponential. Never deploy to production without verifying opcache.validate_timestamps=0.
Conclusion #
Containerizing PHP requires more than just copying files into an image. It requires an architectural mindset. By separating concerns (Nginx vs. FPM), implementing multi-stage builds, and hardening security with non-root users, you create a system that is robust, scalable, and secure.
This setup prepares you for the next step: Kubernetes. The Dockerfile and concepts we built here translate 1:1 to Pod definitions in K8s.
Next Steps for You: #
- Implement Secrets: Replace environment variables in
docker-compose.ymlwith Docker Secrets for sensitive passwords. - CI/CD Pipeline: Create a GitHub Action that builds this image and pushes it to Docker Hub or AWS ECR.
- Observability: Add a sidecar container like Promtail or Fluentd to ship logs to Grafana Loki.
Welcome to the future of PHP deployment. Happy coding!
Found this guide helpful? Subscribe to the PHP DevPro newsletter for more deep dives into High-Performance PHP, Async PHP, and System Architecture.