Back to blog
DevOps 14 min read

Docker for PHP in production: Lessons learned

After years of running PHP in Docker containers in production, here's what works, what doesn't, and what I wish someone had told me from the start.

August 2024 · Updated August 2024

TL;DR

Docker works great for PHP in production, but the devil is in the details. Use multi-stage builds to keep images small. Configure PHP-FPM properly for container workloads. Handle signals correctly for graceful shutdowns. Log to stdout. And never run as root.

Docker werkt uitstekend voor PHP in productie, maar de duivel zit in de details. Gebruik multi-stage builds om images klein te houden. Configureer PHP-FPM correct voor container workloads. Behandel signalen correct voor graceful shutdowns. Log naar stdout. En draai nooit als root.

Docker funciona muy bien para PHP en producción, pero el diablo está en los detalles. Usa multi-stage builds para mantener las imágenes pequeñas. Configura PHP-FPM correctamente para cargas de trabajo en contenedores. Maneja las señales correctamente para apagados elegantes. Registra en stdout. Y nunca ejecutes como root.

Why Docker for PHP?

Before diving into lessons learned, let's address the "why." PHP has worked fine without Docker for decades. Why add the complexity?

  • Consistency: The same container runs locally, in CI, and in production
  • Isolation: Multiple PHP versions on the same host without conflicts
  • Immutability: No "it works on my machine" - the image is the artifact
  • Scaling: Spin up new containers in seconds, not minutes
  • Infrastructure as Code: The Dockerfile documents exactly how your environment is configured

That said, Docker introduces its own complexity. Here's what I've learned running PHP containers in production.

Lesson 1: Multi-stage builds are essential

A naive Dockerfile that installs Composer, npm, and all development dependencies creates massive images. Multi-stage builds solve this elegantly.

# Stage 1: Build assets
FROM node:20-alpine AS assets
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY resources/ resources/
COPY vite.config.js tailwind.config.js postcss.config.js ./
RUN npm run build

# Stage 2: Install PHP dependencies
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

# Stage 3: Production image
FROM php:8.3-fpm-alpine AS production

# Install production extensions
RUN apk add --no-cache \
    libpng-dev \
    libzip-dev \
    icu-dev \
    && docker-php-ext-install \
    pdo_mysql \
    gd \
    zip \
    intl \
    opcache \
    pcntl

# Copy application
WORKDIR /var/www/html
COPY --from=vendor /app/vendor vendor/
COPY --from=assets /app/public/build public/build/
COPY . .

# Set ownership
RUN chown -R www-data:www-data storage bootstrap/cache

USER www-data

EXPOSE 9000
CMD ["php-fpm"]

This approach:

  • Keeps Node.js and npm out of the production image
  • Keeps Composer out of the production image
  • Only includes production dependencies
  • Results in an image around 100MB instead of 500MB+

Lesson 2: Configure PHP-FPM for containers

The default PHP-FPM configuration is designed for bare-metal servers, not containers. Several settings need adjustment.

Process management

Containers typically run a single service. The dynamic process manager wastes resources in this context:

; /usr/local/etc/php-fpm.d/www.conf

; Use static process management
pm = static

; Set based on container memory allocation
; Rule of thumb: (container memory - 128MB) / average request memory
pm.max_children = 10

; Request lifecycle
pm.max_requests = 500
request_terminate_timeout = 60s

Logging configuration

Docker expects logs on stdout/stderr. PHP-FPM defaults to files:

; Log to stderr for Docker
access.log = /proc/self/fd/2
error_log = /proc/self/fd/2

; Capture worker stdout
catch_workers_output = yes
decorate_workers_output = no

OPcache for production

; /usr/local/etc/php/conf.d/opcache.ini
opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.preload=/var/www/html/preload.php
opcache.preload_user=www-data

Important: OPcache Validation

Setting opcache.validate_timestamps=0 means PHP won't check if files have changed. This is correct for production (deploy new containers for code changes) but will confuse you in development if you forget to disable it.

Lesson 3: Handle signals properly

When Kubernetes sends SIGTERM to your container (or Docker stops it), you want graceful shutdown. PHP-FPM handles this... if you configure it correctly.

; Allow graceful shutdown
process_control_timeout = 10

And in your Dockerfile, don't wrap php-fpm in a shell script:

# Bad - signals go to shell, not php-fpm
CMD ["sh", "-c", "php-fpm"]

# Good - signals go directly to php-fpm
CMD ["php-fpm"]

For Laravel queue workers in containers, ensure proper signal handling:

# Queue worker container
CMD ["php", "artisan", "queue:work", "--tries=3", "--timeout=60"]

Laravel's queue worker listens for SIGTERM and finishes the current job before exiting. But you need to configure Kubernetes to wait:

# kubernetes deployment
spec:
  terminationGracePeriodSeconds: 120  # Wait for job to finish
  containers:
    - name: worker
      lifecycle:
        preStop:
          exec:
            command: ["php", "artisan", "queue:restart"]

Lesson 4: Never run as root

Running containers as root is a security risk. If an attacker escapes the container, they have root on the host (in some configurations).

# Create a non-root user
RUN addgroup -g 1000 app && adduser -u 1000 -G app -D app

# Set ownership before switching users
RUN chown -R app:app /var/www/html/storage /var/www/html/bootstrap/cache

# Switch to non-root user
USER app

This requires careful attention to file permissions. The most common issues:

  • Storage directory not writable
  • Log files not writable
  • Cache directories not writable

Lesson 5: Health checks matter

Kubernetes and load balancers need to know if your container is healthy. A simple HTTP endpoint isn't enough - it needs to actually check dependencies.

// routes/web.php
Route::get('/health', function () {
    $checks = [];

    // Database connection
    try {
        DB::connection()->getPdo();
        $checks['database'] = 'ok';
    } catch (\Exception $e) {
        $checks['database'] = 'failed: ' . $e->getMessage();
    }

    // Redis connection
    try {
        Redis::ping();
        $checks['redis'] = 'ok';
    } catch (\Exception $e) {
        $checks['redis'] = 'failed: ' . $e->getMessage();
    }

    // Storage writable
    $testFile = storage_path('health-check-' . uniqid());
    if (@file_put_contents($testFile, 'test') && @unlink($testFile)) {
        $checks['storage'] = 'ok';
    } else {
        $checks['storage'] = 'failed: not writable';
    }

    $allHealthy = !in_array(false, array_map(
        fn($v) => $v === 'ok',
        $checks
    ));

    return response()->json([
        'status' => $allHealthy ? 'healthy' : 'unhealthy',
        'checks' => $checks,
    ], $allHealthy ? 200 : 503);
});
# Dockerfile healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:9000/health || exit 1

Lesson 6: Separate concerns with multiple containers

Don't try to run everything in one container. The "one process per container" philosophy exists for good reasons.

A typical Laravel deployment might have:

# docker-compose.prod.yml
services:
  nginx:
    image: nginx:alpine
    volumes:
      - static_assets:/var/www/html/public:ro
    depends_on:
      - php

  php:
    image: myapp:latest
    environment:
      - APP_ENV=production

  worker:
    image: myapp:latest
    command: php artisan queue:work
    environment:
      - APP_ENV=production

  scheduler:
    image: myapp:latest
    command: php artisan schedule:work
    environment:
      - APP_ENV=production

Each container has a single responsibility:

  • nginx: Serves static files, proxies PHP requests
  • php: Handles HTTP requests via PHP-FPM
  • worker: Processes queue jobs
  • scheduler: Runs scheduled tasks

Lesson 7: Environment configuration

Never bake secrets into images. Use environment variables:

# Don't do this
ENV APP_KEY=base64:your-key-here  # BAD!

# Do this instead - inject at runtime
ENV APP_KEY=${APP_KEY}

For Laravel, consider the configuration caching carefully:

# Build-time caching (env must match at runtime)
RUN php artisan config:cache

# OR runtime caching (via entrypoint)
COPY docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
#!/bin/sh
# docker-entrypoint.sh

# Cache configuration at container start
php artisan config:cache
php artisan route:cache
php artisan view:cache

# Execute the main command
exec "$@"

Lesson 8: Image tagging strategy

Don't just use :latest. It's impossible to track what's deployed.

# Good tagging strategy
docker build -t myapp:${GIT_SHA} -t myapp:${GIT_TAG} .

# Example tags:
# myapp:abc123def  (commit hash - always unique)
# myapp:v1.2.3     (semantic version - for releases)
# myapp:main       (branch - for development)

Your CI pipeline should push multiple tags:

# GitHub Actions
- name: Build and push
  uses: docker/build-push-action@v5
  with:
    push: true
    tags: |
      myregistry/myapp:${{ github.sha }}
      myregistry/myapp:${{ github.ref_name }}

Lesson 9: Layer caching in CI

Docker builds can be slow in CI without proper caching. Use BuildKit and cache mounts:

# syntax=docker/dockerfile:1.4

# Cache Composer downloads
RUN --mount=type=cache,target=/root/.composer/cache \
    composer install --no-dev --optimize-autoloader

# Cache npm downloads
RUN --mount=type=cache,target=/root/.npm \
    npm ci
# GitHub Actions with layer caching
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

Lesson 10: Debugging in production

You will need to debug production containers. Make it possible:

# Include debugging tools in a separate stage
FROM production AS debug
USER root
RUN apk add --no-cache \
    strace \
    tcpdump \
    curl \
    vim
USER www-data

But never deploy the debug image to production - use it only for troubleshooting:

# Deploy production image normally
kubectl set image deployment/myapp myapp=myregistry/myapp:abc123

# For debugging, temporarily swap in debug image
kubectl debug -it pod/myapp-xxx --image=myregistry/myapp:abc123-debug

Common pitfalls to avoid

  • Storing sessions in files: Use Redis or database sessions - containers are ephemeral
  • Local file uploads: Use S3 or another object store
  • Running Composer in production: Install dependencies at build time
  • Ignoring container logs: Set up proper log aggregation
  • Hardcoding hostnames: Use environment variables and DNS

The production checklist

Before deploying a PHP Docker container to production, verify:

  1. Multi-stage build with minimal final image
  2. Running as non-root user
  3. Proper signal handling for graceful shutdown
  4. Health check endpoint that verifies dependencies
  5. Logs going to stdout/stderr
  6. OPcache configured for production
  7. Secrets injected via environment, not baked in
  8. Proper resource limits configured
  9. Persistent storage (sessions, uploads) using external services

Need help containerizing your PHP application for production? I've deployed Docker containers for organizations of all sizes. Let's talk.

Waarom Docker voor PHP?

Voordat we ingaan op geleerde lessen, laten we het "waarom" bespreken. PHP werkt al decennia prima zonder Docker. Waarom de complexiteit toevoegen?

  • Consistentie: Dezelfde container draait lokaal, in CI en in productie
  • Isolatie: Meerdere PHP-versies op dezelfde host zonder conflicten
  • Onveranderlijkheid: Geen "het werkt op mijn machine" - de image is het artifact
  • Schaalbaarheid: Start nieuwe containers in seconden, niet minuten
  • Infrastructure as Code: Het Dockerfile documenteert precies hoe je omgeving is geconfigureerd

Dat gezegd hebbende, Docker introduceert zijn eigen complexiteit. Dit is wat ik heb geleerd van het draaien van PHP containers in productie.

Les 1: Multi-stage builds zijn essentieel

Een naïef Dockerfile dat Composer, npm en alle development dependencies installeert creëert enorme images. Multi-stage builds lossen dit elegant op.

# Stage 1: Build assets
FROM node:20-alpine AS assets
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY resources/ resources/
COPY vite.config.js tailwind.config.js postcss.config.js ./
RUN npm run build

# Stage 2: Install PHP dependencies
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

# Stage 3: Production image
FROM php:8.3-fpm-alpine AS production

# Install production extensions
RUN apk add --no-cache \
    libpng-dev \
    libzip-dev \
    icu-dev \
    && docker-php-ext-install \
    pdo_mysql \
    gd \
    zip \
    intl \
    opcache \
    pcntl

# Copy application
WORKDIR /var/www/html
COPY --from=vendor /app/vendor vendor/
COPY --from=assets /app/public/build public/build/
COPY . .

# Set ownership
RUN chown -R www-data:www-data storage bootstrap/cache

USER www-data

EXPOSE 9000
CMD ["php-fpm"]

Deze aanpak:

  • Houdt Node.js en npm uit de productie image
  • Houdt Composer uit de productie image
  • Bevat alleen productie dependencies
  • Resulteert in een image van ongeveer 100MB in plaats van 500MB+

Les 2: Configureer PHP-FPM voor containers

De standaard PHP-FPM configuratie is ontworpen voor bare-metal servers, niet containers. Verschillende instellingen moeten worden aangepast.

Process management

Containers draaien meestal één service. De dynamische process manager verspilt resources in deze context:

; /usr/local/etc/php-fpm.d/www.conf

; Gebruik statisch process management
pm = static

; Stel in op basis van container geheugen allocatie
; Vuistregel: (container geheugen - 128MB) / gemiddeld request geheugen
pm.max_children = 10

; Request levenscyclus
pm.max_requests = 500
request_terminate_timeout = 60s

Logging configuratie

Docker verwacht logs op stdout/stderr. PHP-FPM defaultt naar bestanden:

; Log naar stderr voor Docker
access.log = /proc/self/fd/2
error_log = /proc/self/fd/2

; Capture worker stdout
catch_workers_output = yes
decorate_workers_output = no

OPcache voor productie

; /usr/local/etc/php/conf.d/opcache.ini
opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.preload=/var/www/html/preload.php
opcache.preload_user=www-data

Belangrijk: OPcache Validatie

Het instellen van opcache.validate_timestamps=0 betekent dat PHP niet controleert of bestanden zijn gewijzigd. Dit is correct voor productie (deploy nieuwe containers voor code wijzigingen) maar zal je in ontwikkeling verwarren als je vergeet het uit te schakelen.

Les 3: Behandel signalen correct

Wanneer Kubernetes SIGTERM naar je container stuurt (of Docker het stopt), wil je een graceful shutdown. PHP-FPM behandelt dit... als je het correct configureert.

; Sta graceful shutdown toe
process_control_timeout = 10

En in je Dockerfile, wrap php-fpm niet in een shell script:

# Slecht - signalen gaan naar shell, niet php-fpm
CMD ["sh", "-c", "php-fpm"]

# Goed - signalen gaan direct naar php-fpm
CMD ["php-fpm"]

Voor Laravel queue workers in containers, zorg voor correcte signaal behandeling:

# Queue worker container
CMD ["php", "artisan", "queue:work", "--tries=3", "--timeout=60"]

Laravel's queue worker luistert naar SIGTERM en maakt de huidige job af voordat het stopt. Maar je moet Kubernetes configureren om te wachten:

# kubernetes deployment
spec:
  terminationGracePeriodSeconds: 120  # Wacht tot job klaar is
  containers:
    - name: worker
      lifecycle:
        preStop:
          exec:
            command: ["php", "artisan", "queue:restart"]

Les 4: Draai nooit als root

Containers als root draaien is een beveiligingsrisico. Als een aanvaller de container ontsnapt, hebben ze root op de host (in sommige configuraties).

# Creëer een non-root gebruiker
RUN addgroup -g 1000 app && adduser -u 1000 -G app -D app

# Stel eigendom in voordat je van gebruiker wisselt
RUN chown -R app:app /var/www/html/storage /var/www/html/bootstrap/cache

# Wissel naar non-root gebruiker
USER app

Dit vereist zorgvuldige aandacht voor bestandsrechten. De meest voorkomende problemen:

  • Storage directory niet schrijfbaar
  • Log bestanden niet schrijfbaar
  • Cache directories niet schrijfbaar

Les 5: Health checks zijn belangrijk

Kubernetes en load balancers moeten weten of je container gezond is. Een simpel HTTP endpoint is niet genoeg - het moet daadwerkelijk dependencies checken.

// routes/web.php
Route::get('/health', function () {
    $checks = [];

    // Database connectie
    try {
        DB::connection()->getPdo();
        $checks['database'] = 'ok';
    } catch (\Exception $e) {
        $checks['database'] = 'failed: ' . $e->getMessage();
    }

    // Redis connectie
    try {
        Redis::ping();
        $checks['redis'] = 'ok';
    } catch (\Exception $e) {
        $checks['redis'] = 'failed: ' . $e->getMessage();
    }

    // Storage schrijfbaar
    $testFile = storage_path('health-check-' . uniqid());
    if (@file_put_contents($testFile, 'test') && @unlink($testFile)) {
        $checks['storage'] = 'ok';
    } else {
        $checks['storage'] = 'failed: not writable';
    }

    $allHealthy = !in_array(false, array_map(
        fn($v) => $v === 'ok',
        $checks
    ));

    return response()->json([
        'status' => $allHealthy ? 'healthy' : 'unhealthy',
        'checks' => $checks,
    ], $allHealthy ? 200 : 503);
});
# Dockerfile healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:9000/health || exit 1

Les 6: Scheid verantwoordelijkheden met meerdere containers

Probeer niet alles in één container te draaien. De "één proces per container" filosofie bestaat om goede redenen.

Een typische Laravel deployment kan hebben:

# docker-compose.prod.yml
services:
  nginx:
    image: nginx:alpine
    volumes:
      - static_assets:/var/www/html/public:ro
    depends_on:
      - php

  php:
    image: myapp:latest
    environment:
      - APP_ENV=production

  worker:
    image: myapp:latest
    command: php artisan queue:work
    environment:
      - APP_ENV=production

  scheduler:
    image: myapp:latest
    command: php artisan schedule:work
    environment:
      - APP_ENV=production

Elke container heeft één verantwoordelijkheid:

  • nginx: Serveert statische bestanden, proxyt PHP requests
  • php: Behandelt HTTP requests via PHP-FPM
  • worker: Verwerkt queue jobs
  • scheduler: Draait geplande taken

Les 7: Omgevingsconfiguratie

Bak nooit secrets in images. Gebruik omgevingsvariabelen:

# Doe dit niet
ENV APP_KEY=base64:your-key-here  # SLECHT!

# Doe dit wel - injecteren tijdens runtime
ENV APP_KEY=${APP_KEY}

Voor Laravel, overweeg de configuratie caching zorgvuldig:

# Build-time caching (env moet matchen tijdens runtime)
RUN php artisan config:cache

# OF runtime caching (via entrypoint)
COPY docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
#!/bin/sh
# docker-entrypoint.sh

# Cache configuratie bij container start
php artisan config:cache
php artisan route:cache
php artisan view:cache

# Voer het hoofdcommando uit
exec "$@"

Les 8: Image tagging strategie

Gebruik niet alleen :latest. Het is onmogelijk om bij te houden wat er gedeployd is.

# Goede tagging strategie
docker build -t myapp:${GIT_SHA} -t myapp:${GIT_TAG} .

# Voorbeeld tags:
# myapp:abc123def  (commit hash - altijd uniek)
# myapp:v1.2.3     (semantic version - voor releases)
# myapp:main       (branch - voor development)

Je CI pipeline moet meerdere tags pushen:

# GitHub Actions
- name: Build and push
  uses: docker/build-push-action@v5
  with:
    push: true
    tags: |
      myregistry/myapp:${{ github.sha }}
      myregistry/myapp:${{ github.ref_name }}

Les 9: Layer caching in CI

Docker builds kunnen traag zijn in CI zonder goede caching. Gebruik BuildKit en cache mounts:

# syntax=docker/dockerfile:1.4

# Cache Composer downloads
RUN --mount=type=cache,target=/root/.composer/cache \
    composer install --no-dev --optimize-autoloader

# Cache npm downloads
RUN --mount=type=cache,target=/root/.npm \
    npm ci
# GitHub Actions met layer caching
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

Les 10: Debuggen in productie

Je zult productie containers moeten debuggen. Maak het mogelijk:

# Voeg debugging tools toe in een aparte stage
FROM production AS debug
USER root
RUN apk add --no-cache \
    strace \
    tcpdump \
    curl \
    vim
USER www-data

Maar deploy nooit de debug image naar productie - gebruik het alleen voor troubleshooting:

# Deploy productie image normaal
kubectl set image deployment/myapp myapp=myregistry/myapp:abc123

# Voor debuggen, tijdelijk debug image gebruiken
kubectl debug -it pod/myapp-xxx --image=myregistry/myapp:abc123-debug

Veelvoorkomende valkuilen om te vermijden

  • Sessies opslaan in bestanden: Gebruik Redis of database sessions - containers zijn tijdelijk
  • Lokale file uploads: Gebruik S3 of een andere object store
  • Composer draaien in productie: Installeer dependencies tijdens build time
  • Container logs negeren: Stel goede log aggregatie in
  • Hostnamen hardcoden: Gebruik omgevingsvariabelen en DNS

De productie checklist

Voordat je een PHP Docker container naar productie deployt, verifieer:

  1. Multi-stage build met minimale finale image
  2. Draait als non-root gebruiker
  3. Correcte signaal behandeling voor graceful shutdown
  4. Health check endpoint dat dependencies verifieert
  5. Logs gaan naar stdout/stderr
  6. OPcache geconfigureerd voor productie
  7. Secrets geïnjecteerd via omgeving, niet ingebakken
  8. Goede resource limits geconfigureerd
  9. Persistente opslag (sessies, uploads) met externe services

Hulp nodig bij het containeriseren van je PHP applicatie voor productie? Ik heb Docker containers gedeployd voor organisaties van alle groottes. Laten we praten.

¿Por qué Docker para PHP?

Antes de profundizar en las lecciones aprendidas, abordemos el "por qué". PHP ha funcionado bien sin Docker durante décadas. ¿Por qué añadir la complejidad?

  • Consistencia: El mismo contenedor se ejecuta localmente, en CI y en producción
  • Aislamiento: Múltiples versiones de PHP en el mismo host sin conflictos
  • Inmutabilidad: No más "funciona en mi máquina" - la imagen es el artefacto
  • Escalabilidad: Inicia nuevos contenedores en segundos, no minutos
  • Infraestructura como Código: El Dockerfile documenta exactamente cómo está configurado tu entorno

Dicho esto, Docker introduce su propia complejidad. Esto es lo que he aprendido ejecutando contenedores PHP en producción.

Lección 1: Las compilaciones multi-etapa son esenciales

Un Dockerfile ingenuo que instala Composer, npm y todas las dependencias de desarrollo crea imágenes masivas. Las compilaciones multi-etapa resuelven esto elegantemente.

# Stage 1: Build assets
FROM node:20-alpine AS assets
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY resources/ resources/
COPY vite.config.js tailwind.config.js postcss.config.js ./
RUN npm run build

# Stage 2: Install PHP dependencies
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

# Stage 3: Production image
FROM php:8.3-fpm-alpine AS production

# Install production extensions
RUN apk add --no-cache \
    libpng-dev \
    libzip-dev \
    icu-dev \
    && docker-php-ext-install \
    pdo_mysql \
    gd \
    zip \
    intl \
    opcache \
    pcntl

# Copy application
WORKDIR /var/www/html
COPY --from=vendor /app/vendor vendor/
COPY --from=assets /app/public/build public/build/
COPY . .

# Set ownership
RUN chown -R www-data:www-data storage bootstrap/cache

USER www-data

EXPOSE 9000
CMD ["php-fpm"]

Este enfoque:

  • Mantiene Node.js y npm fuera de la imagen de producción
  • Mantiene Composer fuera de la imagen de producción
  • Solo incluye dependencias de producción
  • Resulta en una imagen de alrededor de 100MB en lugar de 500MB+

Lección 2: Configura PHP-FPM para contenedores

La configuración predeterminada de PHP-FPM está diseñada para servidores bare-metal, no contenedores. Varios ajustes necesitan modificación.

Gestión de procesos

Los contenedores típicamente ejecutan un solo servicio. El gestor de procesos dinámico desperdicia recursos en este contexto:

; /usr/local/etc/php-fpm.d/www.conf

; Usar gestión de procesos estática
pm = static

; Establecer basado en la asignación de memoria del contenedor
; Regla general: (memoria del contenedor - 128MB) / memoria promedio por solicitud
pm.max_children = 10

; Ciclo de vida de solicitudes
pm.max_requests = 500
request_terminate_timeout = 60s

Configuración de logs

Docker espera logs en stdout/stderr. PHP-FPM usa archivos por defecto:

; Registrar en stderr para Docker
access.log = /proc/self/fd/2
error_log = /proc/self/fd/2

; Capturar stdout del worker
catch_workers_output = yes
decorate_workers_output = no

OPcache para producción

; /usr/local/etc/php/conf.d/opcache.ini
opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.preload=/var/www/html/preload.php
opcache.preload_user=www-data

Importante: Validación de OPcache

Configurar opcache.validate_timestamps=0 significa que PHP no verificará si los archivos han cambiado. Esto es correcto para producción (despliega nuevos contenedores para cambios de código) pero te confundirá en desarrollo si olvidas deshabilitarlo.

Lección 3: Maneja las señales correctamente

Cuando Kubernetes envía SIGTERM a tu contenedor (o Docker lo detiene), quieres un apagado elegante. PHP-FPM maneja esto... si lo configuras correctamente.

; Permitir apagado elegante
process_control_timeout = 10

Y en tu Dockerfile, no envuelvas php-fpm en un script de shell:

# Malo - las señales van al shell, no a php-fpm
CMD ["sh", "-c", "php-fpm"]

# Bueno - las señales van directamente a php-fpm
CMD ["php-fpm"]

Para workers de cola de Laravel en contenedores, asegura el manejo adecuado de señales:

# Contenedor de queue worker
CMD ["php", "artisan", "queue:work", "--tries=3", "--timeout=60"]

El queue worker de Laravel escucha SIGTERM y termina el trabajo actual antes de salir. Pero necesitas configurar Kubernetes para esperar:

# kubernetes deployment
spec:
  terminationGracePeriodSeconds: 120  # Esperar a que termine el trabajo
  containers:
    - name: worker
      lifecycle:
        preStop:
          exec:
            command: ["php", "artisan", "queue:restart"]

Lección 4: Nunca ejecutes como root

Ejecutar contenedores como root es un riesgo de seguridad. Si un atacante escapa del contenedor, tiene root en el host (en algunas configuraciones).

# Crear un usuario no root
RUN addgroup -g 1000 app && adduser -u 1000 -G app -D app

# Establecer propiedad antes de cambiar de usuario
RUN chown -R app:app /var/www/html/storage /var/www/html/bootstrap/cache

# Cambiar a usuario no root
USER app

Esto requiere atención cuidadosa a los permisos de archivos. Los problemas más comunes:

  • Directorio storage no escribible
  • Archivos de log no escribibles
  • Directorios de caché no escribibles

Lección 5: Los health checks importan

Kubernetes y los balanceadores de carga necesitan saber si tu contenedor está saludable. Un simple endpoint HTTP no es suficiente - necesita verificar las dependencias realmente.

// routes/web.php
Route::get('/health', function () {
    $checks = [];

    // Conexión a la base de datos
    try {
        DB::connection()->getPdo();
        $checks['database'] = 'ok';
    } catch (\Exception $e) {
        $checks['database'] = 'failed: ' . $e->getMessage();
    }

    // Conexión a Redis
    try {
        Redis::ping();
        $checks['redis'] = 'ok';
    } catch (\Exception $e) {
        $checks['redis'] = 'failed: ' . $e->getMessage();
    }

    // Storage escribible
    $testFile = storage_path('health-check-' . uniqid());
    if (@file_put_contents($testFile, 'test') && @unlink($testFile)) {
        $checks['storage'] = 'ok';
    } else {
        $checks['storage'] = 'failed: not writable';
    }

    $allHealthy = !in_array(false, array_map(
        fn($v) => $v === 'ok',
        $checks
    ));

    return response()->json([
        'status' => $allHealthy ? 'healthy' : 'unhealthy',
        'checks' => $checks,
    ], $allHealthy ? 200 : 503);
});
# Dockerfile healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
  CMD curl -f http://localhost:9000/health || exit 1

Lección 6: Separa responsabilidades con múltiples contenedores

No intentes ejecutar todo en un contenedor. La filosofía de "un proceso por contenedor" existe por buenas razones.

Un despliegue típico de Laravel podría tener:

# docker-compose.prod.yml
services:
  nginx:
    image: nginx:alpine
    volumes:
      - static_assets:/var/www/html/public:ro
    depends_on:
      - php

  php:
    image: myapp:latest
    environment:
      - APP_ENV=production

  worker:
    image: myapp:latest
    command: php artisan queue:work
    environment:
      - APP_ENV=production

  scheduler:
    image: myapp:latest
    command: php artisan schedule:work
    environment:
      - APP_ENV=production

Cada contenedor tiene una sola responsabilidad:

  • nginx: Sirve archivos estáticos, hace proxy de solicitudes PHP
  • php: Maneja solicitudes HTTP vía PHP-FPM
  • worker: Procesa trabajos de cola
  • scheduler: Ejecuta tareas programadas

Lección 7: Configuración de entorno

Nunca incluyas secretos en las imágenes. Usa variables de entorno:

# No hagas esto
ENV APP_KEY=base64:your-key-here  # ¡MAL!

# Haz esto en su lugar - inyectar en tiempo de ejecución
ENV APP_KEY=${APP_KEY}

Para Laravel, considera el almacenamiento en caché de configuración cuidadosamente:

# Caché en tiempo de compilación (env debe coincidir en tiempo de ejecución)
RUN php artisan config:cache

# O caché en tiempo de ejecución (vía entrypoint)
COPY docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
#!/bin/sh
# docker-entrypoint.sh

# Cachear configuración al iniciar el contenedor
php artisan config:cache
php artisan route:cache
php artisan view:cache

# Ejecutar el comando principal
exec "$@"

Lección 8: Estrategia de etiquetado de imágenes

No uses solo :latest. Es imposible rastrear qué está desplegado.

# Buena estrategia de etiquetado
docker build -t myapp:${GIT_SHA} -t myapp:${GIT_TAG} .

# Etiquetas de ejemplo:
# myapp:abc123def  (hash de commit - siempre único)
# myapp:v1.2.3     (versión semántica - para releases)
# myapp:main       (rama - para desarrollo)

Tu pipeline de CI debería empujar múltiples etiquetas:

# GitHub Actions
- name: Build and push
  uses: docker/build-push-action@v5
  with:
    push: true
    tags: |
      myregistry/myapp:${{ github.sha }}
      myregistry/myapp:${{ github.ref_name }}

Lección 9: Caché de capas en CI

Las compilaciones de Docker pueden ser lentas en CI sin el almacenamiento en caché adecuado. Usa BuildKit y montajes de caché:

# syntax=docker/dockerfile:1.4

# Cachear descargas de Composer
RUN --mount=type=cache,target=/root/.composer/cache \
    composer install --no-dev --optimize-autoloader

# Cachear descargas de npm
RUN --mount=type=cache,target=/root/.npm \
    npm ci
# GitHub Actions con caché de capas
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v3

- name: Build and push
  uses: docker/build-push-action@v5
  with:
    cache-from: type=gha
    cache-to: type=gha,mode=max

Lección 10: Depuración en producción

Necesitarás depurar contenedores de producción. Hazlo posible:

# Incluir herramientas de depuración en una etapa separada
FROM production AS debug
USER root
RUN apk add --no-cache \
    strace \
    tcpdump \
    curl \
    vim
USER www-data

Pero nunca despliegues la imagen de depuración en producción - úsala solo para solución de problemas:

# Desplegar imagen de producción normalmente
kubectl set image deployment/myapp myapp=myregistry/myapp:abc123

# Para depuración, intercambiar temporalmente la imagen de depuración
kubectl debug -it pod/myapp-xxx --image=myregistry/myapp:abc123-debug

Errores comunes a evitar

  • Almacenar sesiones en archivos: Usa sesiones de Redis o base de datos - los contenedores son efímeros
  • Cargas de archivos locales: Usa S3 u otro almacén de objetos
  • Ejecutar Composer en producción: Instala dependencias en tiempo de compilación
  • Ignorar logs del contenedor: Configura agregación de logs adecuada
  • Codificar hostnames: Usa variables de entorno y DNS

La lista de verificación de producción

Antes de desplegar un contenedor Docker de PHP en producción, verifica:

  1. Compilación multi-etapa con imagen final mínima
  2. Ejecutándose como usuario no root
  3. Manejo adecuado de señales para apagado elegante
  4. Endpoint de health check que verifica dependencias
  5. Logs van a stdout/stderr
  6. OPcache configurado para producción
  7. Secretos inyectados vía entorno, no incluidos
  8. Límites de recursos adecuados configurados
  9. Almacenamiento persistente (sesiones, cargas) usando servicios externos

¿Necesitas ayuda para containerizar tu aplicación PHP para producción? He desplegado contenedores Docker para organizaciones de todos los tamaños. Hablemos.

Related posts

Ready to containerise your PHP application?

From Dockerfiles to Kubernetes deployments, I can help you build a robust container infrastructure.

Get in touch