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:
- Multi-stage build with minimal final image
- Running as non-root user
- Proper signal handling for graceful shutdown
- Health check endpoint that verifies dependencies
- Logs going to stdout/stderr
- OPcache configured for production
- Secrets injected via environment, not baked in
- Proper resource limits configured
- 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:
- Multi-stage build met minimale finale image
- Draait als non-root gebruiker
- Correcte signaal behandeling voor graceful shutdown
- Health check endpoint dat dependencies verifieert
- Logs gaan naar stdout/stderr
- OPcache geconfigureerd voor productie
- Secrets geïnjecteerd via omgeving, niet ingebakken
- Goede resource limits geconfigureerd
- 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:
- Compilación multi-etapa con imagen final mínima
- Ejecutándose como usuario no root
- Manejo adecuado de señales para apagado elegante
- Endpoint de health check que verifica dependencias
- Logs van a stdout/stderr
- OPcache configurado para producción
- Secretos inyectados vía entorno, no incluidos
- Límites de recursos adecuados configurados
- 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.