Skip to main content
  1. Languages/
  2. PHP Guides/

Mastering PHP Containerization: A Production-Ready Docker Guide

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

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
#

  1. Docker Engine & CLI: Version 24.0+.
  2. Docker Compose: Version 2.20+ (We will use the modern docker compose command, not the legacy docker-compose).
  3. PHP 8.2+: Installed locally for IDE intelligence (optional but recommended).
  4. Composer: For dependency management.
  5. 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.yml

The 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:

graph TD subgraph "Docker Network" Client[Client Browser] -->|HTTP/HTTPS| Nginx[Nginx Container] style Nginx fill:#009688,stroke:#333,stroke-width:2px,color:#fff style PHP fill:#777BB4,stroke:#333,stroke-width:2px,color:#fff style DB fill:#e38c00,stroke:#333,stroke-width:2px,color:#fff style Redis fill:#d82c20,stroke:#333,stroke-width:2px,color:#fff Nginx -->|Static Files| Storage[(Volume/Disk)] Nginx -->|FastCGI :9000| PHP[PHP-FPM Container] PHP -->|TCP :3306| DB[(MySQL Database)] PHP -->|TCP :6379| Redis[(Redis Cache)] end classDef container fill:#f9f9f9,stroke:#333,stroke-width:1px;

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.

  1. Build Stage: Contains generic build tools, git, and unzip. We use this to run Composer and install dependencies.
  2. 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=tracing

docker/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/stderr

Step 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
#

  1. Open your terminal.
  2. Navigate to the project root.
  3. Run:
    docker compose up -d --build
  4. 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_pass in Nginx config matches the service name in docker-compose.yml (e.g., app), not localhost. 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:
#

  1. Implement Secrets: Replace environment variables in docker-compose.yml with Docker Secrets for sensitive passwords.
  2. CI/CD Pipeline: Create a GitHub Action that builds this image and pushes it to Docker Hub or AWS ECR.
  3. 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.