Imágenes Docker más pequeñas para PHP en producción: multi-stage, OPcache y cero dependencias de desarrollo
Si tu imagen Docker de PHP pasa del giga, el problema casi nunca es tu código: son el toolchain de compilación, las dependencias de desarrollo y node_modules viajando a producción. Estas son las seis decisiones que la adelgazan, con OPcache bien configurado y sin romper el despliegue.
Mide antes de tocar nada
Sin medición, cualquier cambio es una superstición. Antes de editar una línea del Dockerfile, mira cuánto pesa la imagen y qué capa pesa.
Cuánto pesa tu imagen y cuánto tarda en construirse
docker images --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}'
docker image inspect app:antes --format '{{.Size}} bytes'
# tiempo de build con y sin caché
time docker build -t app:antes .Anota tamaño, tiempo de build en frío y con caché: vas a repetir la medición al final, y sin esos dos puntos no hay antes/después.
Qué capa pesa: docker history y dive
docker history --no-trunc --format 'table {{.Size}}\t{{.CreatedBy}}' app:antes | head -20
dive app:antesdocker history da el tamaño por capa y el comando que la creó; dive deja recorrer el sistema de archivos dentro de cada una. En una app PHP el top de culpables es siempre el mismo: librerías -dev y compiladores que ya hicieron su trabajo, node_modules, cachés de Composer y npm, y el código de test que nunca corre en producción.
Lee también
Qué se queda en runtime y qué solo hacía falta para construir
Hazte una pregunta por cada capa que veas: "¿esto hace falta a las 3 de la mañana cuando el contenedor está sirviendo tráfico?". El compilador de C, las cabeceras de desarrollo, el gestor de paquetes, la CLI de Composer y node_modules responden que no. Ese es el peso que vas a cortar.
Decisión 1: la imagen base (Alpine, Debian slim o completa)
php:8.4-fpm-alpine: la más pequeña y sus límites con las extensiones
Es la base más liviana de la familia oficial y suele arrancar bien. El costo real no es el tamaño, es musl: algunas extensiones se compilan distinto, necesitan sus librerías de desarrollo y hay paquetes que no tienen build precompilado. Si tu lista de extensiones es corta y común (pdo_mysql, opcache, intl, zip, redis), Alpine te va a costar poco.
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev \
&& apk add --no-cache icu-libs libzip \
&& docker-php-ext-install -j"$(nproc)" intl opcache pdo_mysql zip \
&& apk del --no-network .build-depsphp:8.4-fpm-bookworm: más grande y sin sorpresas con glibc
Sobre Debian usa glibc, la misma que encontrarán los binarios precompilados de casi cualquier extensión o herramienta que instales después. La imagen final pesa más, pero pierdes menos horas peleando con dependencias nativas. El patrón de instalación es el mismo con apt:
RUN apt-get update \
&& apt-get install -y --no-install-recommends libicu-dev libzip-dev \
&& docker-php-ext-install -j"$(nproc)" intl opcache pdo_mysql zip \
&& apt-get purge -y --auto-remove libicu-dev libzip-dev \
&& rm -rf /var/lib/apt/lists/*Elegir por tus extensiones, no por moda
La regla práctica: si todas tus extensiones entran en la lista de arriba, elige Alpine. Si necesitas algo que en musl no compila limpio o que solo se distribuye precompilado para glibc, elige Debian y no lo pienses más. Cambiar de distribución no es una decisión de identidad.
Decisión 2: construir en varias etapas
Un build multietapa (multi-stage) separa "lo que necesito para construir" de "lo que necesito para servir". Es el cambio que más peso quita, porque la etapa final solo recibe el resultado.
Etapa de build con Composer (--no-dev, autoloader optimizado)
La clave es copiar primero los archivos de dependencias: si composer.json y composer.lock no cambian, la capa de instalación se reutiliza de la caché y el build solo recompila lo tuyo.
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-interaction --prefer-dist --optimize-autoloader--no-dev deja fuera PHPUnit y compañía, y --optimize-autoloader genera el mapa de clases en vez de resolverlas buscando en disco. El remate es el autoloader autoritativo, que le dice a Composer que confíe en el mapa y no consulte el sistema de archivos:
RUN composer dump-autoload --classmap-authoritative --no-devOjo con el orden: --classmap-authoritative construye el mapa con los archivos que encuentra en ese momento. Si lo ejecutas antes de copiar el código de tu aplicación, las clases de App\ no entran en el mapa y en producción verás un error de clase inexistente. Copia el código en esa misma etapa (o ejecuta el dump-autoload en la etapa final, después de copiar todo) y no tendrás ese problema.
Etapa de assets con Node y Vite: compilar aquí, copiar solo public/build
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY resources ./resources
COPY vite.config.js ./
RUN npm run buildAquí se compilan los assets y de aquí solo sale public/build. node_modules se queda en esta etapa para siempre: no tiene ninguna razón para estar en la imagen final.
Qué cruza a la imagen final (y qué se queda fuera para siempre)
FROM php:8.4-fpm-alpine AS runtime
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev \
&& apk add --no-cache icu-libs libzip fcgi curl \
&& docker-php-ext-install -j"$(nproc)" intl opcache pdo_mysql zip \
&& apk del --no-network .build-deps \
&& curl -fsSL https://raw.githubusercontent.com/renatomefi/php-fpm-healthcheck/master/php-fpm-healthcheck \
-o /usr/local/bin/php-fpm-healthcheck \
&& chmod +x /usr/local/bin/php-fpm-healthcheck
WORKDIR /var/www/html
# 1) código (vendor y public/build están en .dockerignore)
COPY . .
# 2) lo construido en las etapas anteriores, con dueño correcto
COPY --from=vendor --chown=www-data:www-data /app/vendor ./vendor
COPY --from=assets --chown=www-data:www-data /app/public/build ./public/build
COPY docker/php/production.ini /usr/local/etc/php/conf.d/99-production.ini
COPY docker/php/zz-app.conf /usr/local/etc/php-fpm.d/zz-app.conf
COPY docker/entrypoint.sh /usr/local/bin/entrypoint
RUN chmod +x /usr/local/bin/entrypoint \
&& mkdir -p storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache \
&& chown -R www-data:www-data storage bootstrap/cache
USER www-data
EXPOSE 9000
STOPSIGNAL SIGQUIT
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD php-fpm-healthcheck || exit 1
ENTRYPOINT ["entrypoint"]
CMD ["php-fpm"]Lo que no cruza a la imagen final: compiladores y cabeceras -dev, node_modules, tests/, .git, las cachés generadas durante el build y cualquier base de datos local. Nada de eso se usa a las 3 de la mañana.
Decisión 3: .dockerignore y el orden de las capas
Qué excluir del contexto de build
El contexto de build se envía completo al daemon antes de empezar, así que un .dockerignore decente también acelera el propio build:
.git
.github
node_modules
vendor
public/build
tests
docs
*.md
.env
.env.*
!.env.example
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
bootstrap/cache/*.php
docker-compose*.yml
.phpunit.cacheCuidado con un detalle de sintaxis: si excluyes una carpeta completa, no puedes volver a incluir un archivo de dentro con !. Excluir el contenido con /* (como arriba) sí te lo permite; aquí las carpetas se recrean con mkdir -p en el Dockerfile.
Copiar composer.json y composer.lock primero: una caché que sobrevive
Docker cachea por capa: cada instrucción se reutiliza si nada anterior cambió. Si copias todo el código antes de instalar dependencias, cualquier edición invalida la instalación de Composer y vuelves a descargar el universo en cada build. El orden correcto es el del ejemplo: manifiestos, instalación, código y compilación.
Decisión 4: OPcache y PHP-FPM en modo producción
Una imagen chica que sirve lento no es un buen negocio. Estas dos configuraciones son las que cambian el rendimiento real.
opcache.validate_timestamps=0: la ganancia y la trampa
; docker/php/production.ini
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
realpath_cache_size=4096K
realpath_cache_ttl=600
expose_php=OffCon validate_timestamps=0 PHP deja de preguntar al sistema de archivos si el script cambió desde la última vez: guarda el opcode en memoria y lo ejecuta. Es una ganancia real en cada petición. Y es la trampa número uno de este tutorial: si despliegas código nuevo y no reciclas el proceso, vas a servir la versión vieja indefinidamente, sin errores, sin avisos, sin nada. El cierre correcto es recargar PHP-FPM en cada despliegue:
# en el deploy, tras reemplazar el contenedor
docker exec app kill -USR2 1 # recarga graceful del master de PHP-FPM
# o, si prefieres lo simple: docker compose up -d --no-deps appNota importante: una recarga limpia de FPM vacía la caché de opcodes, así que las primeras peticiones posteriores van a ser más lentas. Es el precio de no servir código viejo.
Memoria, max_accelerated_files y preload
opcache.memory_consumption (256 MB es un punto de partida razonable para una app con muchas dependencias) y opcache.max_accelerated_files tienen que ser suficientes; si se quedan cortos, OPcache expulsa entradas y el beneficio se esfuma, así que revisa sus estadísticas en vez de adivinar. opcache.preload es opcional y no es gratis: exige declarar opcache.preload_user cuando el proceso no corre como root y puede esconder errores que solo aparecen cuando algo cambia.
pm.max_children y pm.max_requests: el control de memoria que importa
; docker/php/zz-app.conf
[www]
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 500
pm.status_path = /statuspm.max_children es tu techo de procesos simultáneos: si no lo dimensionas con la memoria del contenedor y el peso real de tu app, tendrás procesos matados por el sistema sin explicación visible. pm.max_requests recicla cada proceso después de N peticiones y es tu red de seguridad contra fugas de memoria; no lo pongas demasiado bajo, porque cada reciclado cuesta. pm.status_path habilita la página de estado que usa el healthcheck.
Decisión 5: usuario sin privilegios, healthcheck y arranque
USER no root y permisos de storage y bootstrap/cache
FPM ya corre sus workers como www-data en la imagen oficial, pero la imagen final no debería tener root como usuario por defecto. Con USER www-data, storage/ y bootstrap/cache/ tienen que pertenecer a ese usuario antes del cambio, o Laravel no podrá escribir logs, sesiones ni vistas compiladas.
HEALTHCHECK y parada limpia del contenedor
php-fpm-healthcheck consulta la página de estado de FPM y falla si el pool no responde: eso evita que un orquestador mande tráfico a un contenedor vivo que no puede servir. STOPSIGNAL SIGQUIT hace que FPM termine las peticiones en curso antes de apagarse: es la diferencia entre un deploy sin errores 502 y uno lleno de ellos.
El arranque queda en un script pequeño, para que las cachés de Laravel se generen con las variables de entorno reales del contenedor:
#!/bin/sh
# docker/entrypoint.sh
set -e
php artisan optimize
exec "$@"¿Por qué en el arranque y no en el build? Porque artisan optimize congela configuración, rutas y vistas: si lo haces durante el build, congelas los valores que existían en la máquina de CI y no los del entorno de producción. También guarda una excepción: route:cache falla si tienes rutas con closures, así que si eso pasa, quita el cacheo de rutas y deja el resto.
Decisión 6: medir el resultado y mirar las vulnerabilidades
Comparar tamaño y tiempo de despliegue antes y después
docker images --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}'
time docker build -t app:despues .
docker history --no-trunc --format 'table {{.Size}}\t{{.CreatedBy}}' app:despues | head -20En un ejemplo de app Laravel 13 con colas y assets compilados con Vite, el salto típico entre "todo en una etapa" y un multietapa ordenado va de algo más de un giga a un rango cercano a los 200 MB, y el build en frío baja de varios minutos a uno o dos. Son números del ejemplo, no una promesa: mide el tuyo.
| Métrica (ejemplo) | Antes | Después |
|---|---|---|
| Tamaño de la imagen | ~1,1 GB | ~170-220 MB |
| Capas de compilación en la imagen final | sí (gcc, cabeceras -dev) | no |
| node_modules dentro de la imagen | sí | no |
| Build con caché caliente | varios minutos | decenas de segundos |
| Peticiones por segundo | depende de OPcache | sube al habilitar OPcache en producción |
Escanear la imagen: menos paquetes, menos superficie
trivy image app:despues
# o, si usas Docker Scout
docker scout cves app:despuesEl mensaje correcto es "menos superficie", no "imagen segura": menos paquetes significa menos vulnerabilidades posibles, pero igual hay que escanear y actualizar la base. Una imagen afinada puede arrastrar un CVE del sistema durante meses si nadie mira.
Errores que rompen producción (y cómo detectarlos)
Extensiones que desaparecieron y assets que no se copiaron
El primer síntoma de una extensión perdida es un error de clase o función no encontrada al arrancar. Se detecta arrancando el contenedor con las variables reales de producción y golpeando una ruta, no solo el healthcheck. Si los assets no se copiaron, la página carga sin estilos.
config:cache, variables de entorno y .env dentro de la imagen
Nunca metas un .env en la imagen: queda en una capa que cualquiera puede inspeccionar. Y si cacheaste la configuración durante el build, el contenedor leerá valores congelados: las variables llegan por entorno en runtime y las cachés se generan al arrancar.
Cambiar código con validate_timestamps=0 y no reciclar PHP-FPM
Es el fallo más difícil de diagnosticar, porque no deja rastro: el despliegue termina bien, el contenedor está sano y el código nuevo no se ejecuta. Con validate_timestamps=0, reciclar el proceso deja de ser opcional y pasa a ser parte del despliegue.
Conclusión
Lo que ganas con esta ruta es concreto: builds más rápidos, despliegues más cortos, menos almacenamiento en el registro y menos superficie de ataque. Lo que no cambia es igual de importante: una imagen pequeña no arregla una consulta N+1, ni un pm.max_children mal dimensionado, ni una caché ausente. Checklist final: mide antes, saca el toolchain y las dependencias de desarrollo de la imagen final, copia solo public/build, ordena las capas para que la caché sobreviva, configura OPcache y FPM con su reciclado en el deploy, corre como usuario sin privilegios y vuelve a medir y escanear.
Si quieres el contexto de despliegue sin contenedores, el paso a paso de deploy de Laravel 13 en un VPS cubre nginx y PHP-FPM con cero downtime, y si tu build vive en CI, CI/CD con GitHub Actions explica dónde enganchar el docker build con caché. Y si aún estás en el entorno de desarrollo, recuerda que Laravel Sail es para tu laptop, no para producción.

