---
title: 'Dockerfile para PHP y Laravel: buenas prácticas | DevSense'
description: 'Cómo construir una imagen de producción de Laravel: Dockerfile multi-stage para PHP-FPM y assets de Vite, caché de capas de Composer y npm, OPcache, non-root, secretos, healthcheck y una sola imagen para web, workers y scheduler.'
faq:
    - { question: '¿Se puede usar la imagen de Laravel Sail en producción?', answer: 'No. La imagen de Sail está pensada para desarrollo: incluye Xdebug, Node.js, clientes de bases de datos y otras herramientas, y la aplicación se ejecuta con php artisan serve, un servidor de desarrollo de un solo hilo. Para producción se construye una imagen mínima aparte sobre PHP-FPM (o FrankenPHP) sin dependencias de desarrollo.' }
    - { question: '¿Para qué sirve un Dockerfile multi-stage para PHP?', answer: 'Composer, Node.js, los paquetes npm y los compiladores solo hacen falta durante el build. En un build multi-stage, las dependencias y los assets se construyen en etapas separadas, y a la imagen final solo se copian vendor/ y public/build. La imagen resulta más pequeña, contiene menos software vulnerable y sus capas se cachean mejor.' }
    - { question: '¿Cuándo ejecutar php artisan config:cache: al construir la imagen o al arrancar el contenedor?', answer: 'Al arrancar el contenedor. config:cache congela en un archivo los valores de las variables de entorno. Si se hace durante el build, la imagen se queda con las variables del entorno de build y se ignoran los ajustes de producción pasados al contenedor. Lo mismo vale para route:cache si las rutas dependen de la configuración.' }
    - { question: '¿Qué elegir para la imagen de PHP: Alpine o Debian?', answer: 'Alpine ofrece un tamaño menor, pero usa musl en lugar de glibc: algunas extensiones tardan más en compilarse, y el comportamiento de ciertas bibliotecas y el rendimiento pueden variar. La imagen Debian (la predeterminada de la imagen oficial de php) es más grande, pero más predecible. Para la mayoría de los proyectos Laravel, la elección sensata es Debian, y Alpine, cuando el tamaño es crítico y ya ha probado su aplicación con musl.' }
published: '2026-10-03'
---
# Dockerfile para PHP y Laravel: una imagen de producción sin nada de sobra

En local tiene Sail: `sail up` y todo funciona. Luego llega el momento de producción, y el camino más rápido es coger la misma imagen, añadir `COPY . .` y arrancarla. Un mes después resulta que la imagen pesa más de un gigabyte, lleva dentro Xdebug y Node.js, la aplicación la sirve `php artisan serve` en un solo hilo, cada build vuelve a descargar todos los paquetes de Composer y el `.env` con las credenciales de la base de datos está directamente en una capa de la imagen.

Una buena imagen de producción de Laravel funciona de otra manera: las dependencias y los assets se construyen en etapas separadas, a la imagen final solo llega lo necesario para ejecutarse, el proceso no corre como root y la configuración llega desde el entorno al arrancar. En este artículo: un Dockerfile completo con la explicación de cada decisión y el esquema para lanzar web, workers y scheduler desde una sola imagen.

**Navegación:** [Todas las herramientas](../) · [Sail: visión general](sail) · [Sail: entorno y despliegue](sail-env-deploy) · [Sail: colas](sail-queues) · [Zero-downtime deployment](../architecture/zero-downtime-deployment-laravel)

## Contenido

* [Por qué la imagen de Sail no sirve para producción](#sail-vs-prod)
* [Arquitectura: una imagen, varios procesos](#image-layout)
* [.dockerignore: lo que no debe entrar en el contexto](#dockerignore)
* [El Dockerfile multi-stage completo](#multistage)
* [Orden de las capas y caché del build](#layer-cache)
* [PHP-FPM y OPcache para producción](#php-config)
* [Configuración y secretos](#config)
* [Web, workers y scheduler desde una sola imagen](#processes)
* [Healthcheck y parada ordenada](#healthchecks)
* [Seguridad de la imagen](#security)
* [Alpine, Debian o FrankenPHP](#base-image)
* [Errores frecuentes](#common-mistakes)
* [Checklist](#checklist)
* [Quiz de autoevaluación](#self-test-quiz)

---

<a id="sail-vs-prod"></a>
## Por qué la imagen de Sail no sirve para producción

Sail es una herramienta de desarrollo excelente, y precisamente por eso no vale para un entorno de producción:

* la aplicación se ejecuta con `php artisan serve` bajo Supervisor: es el servidor integrado de PHP para desarrollo, no PHP-FPM;
* dentro hay Xdebug, Node.js, npm, clientes de MySQL/PostgreSQL y otras utilidades: más tamaño y más superficie de ataque;
* el código se monta desde el host como volumen, no se copia a la imagen: la imagen por sí sola no contiene la aplicación;
* los permisos y el UID se ajustan al usuario del host al arrancar el contenedor.

La imagen de producción es un artefacto independiente con su propio Dockerfile. Sail, mientras tanto, sigue usándose para el desarrollo local.

---

<a id="image-layout"></a>
## Arquitectura: una imagen, varios procesos

El principio «un proceso por contenedor» significa para Laravel: **una sola imagen de la aplicación** desde la que se lanzan distintos procesos.

```
                 ┌──────────────────────┐
  HTTP ────────► │ web (nginx)          │ static files from public/
                 └──────────┬───────────┘
                            │ FastCGI :9000
                 ┌──────────▼───────────┐
                 │ app (php-fpm)        │ ┐
                 └──────────────────────┘ │
                 ┌──────────────────────┐ │  same image,
                 │ queue (queue:work)   │ ├─ different command
                 └──────────────────────┘ │
                 ┌──────────────────────┐ │
                 │ scheduler            │ ┘
                 │ (schedule:work)      │
                 └──────────────────────┘
```

Así, todos los procesos tienen garantizado el mismo código y las mismas extensiones, y escalan de forma independiente: más workers durante una importación, más PHP-FPM en las horas punta. nginx se construye en una pequeña etapa aparte a la que solo se copia `public/`.

---

<a id="dockerignore"></a>
## .dockerignore: lo que no debe entrar en el contexto

Todo lo que hay en el directorio del build se envía al daemon de Docker como contexto. Sin `.dockerignore`, `COPY . .` meterá en la imagen `.git`, el `vendor/` local, `node_modules/` y, lo más peligroso, `.env`.

```gitignore
# .dockerignore
.git
.env
.env.*
!.env.example
node_modules
vendor
public/build
public/hot
public/storage
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*.php
tests
docker-compose*.yml
*.log
```

`vendor/` y `public/build/` se excluyen porque se construyen dentro de Docker: con la versión correcta de PHP, sin paquetes de desarrollo y de forma reproducible.

---

<a id="multistage"></a>
## El Dockerfile multi-stage completo

```dockerfile
# syntax=docker/dockerfile:1
ARG PHP_VERSION=8.5

# ---------- base: PHP runtime with the extensions the app needs ----------
FROM php:${PHP_VERSION}-fpm AS base

COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions pdo_pgsql redis intl zip bcmath pcntl \
 && mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

COPY docker/php/conf.d/ $PHP_INI_DIR/conf.d/
COPY docker/php-fpm/zz-app.conf /usr/local/etc/php-fpm.d/zz-app.conf

WORKDIR /var/www/html

# ---------- vendor: production Composer dependencies ----------
FROM base AS vendor

ENV COMPOSER_HOME=/tmp/composer \
    COMPOSER_CACHE_DIR=/tmp/composer/cache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/composer/cache \
    composer install --no-dev --no-scripts --no-autoloader \
        --prefer-dist --no-interaction --no-progress

# ---------- assets: Vite build ----------
FROM node:24-alpine AS assets
WORKDIR /app

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund

COPY vite.config.* ./
COPY resources/ ./resources/
RUN npm run build

# ---------- runtime: the image that runs in production ----------
FROM base AS runtime

COPY --from=vendor /var/www/html/vendor ./vendor
COPY . .
COPY --from=assets /app/public/build ./public/build

# Composer is mounted only for this step and never ends up in the image.
RUN --mount=type=bind,from=composer:2,source=/usr/bin/composer,target=/usr/local/bin/composer \
    composer dump-autoload --optimize --no-dev --no-interaction \
 && mkdir -p storage/framework/cache storage/framework/sessions storage/framework/views \
             storage/logs bootstrap/cache \
 && chown -R www-data:www-data storage bootstrap/cache

COPY --chmod=755 docker/entrypoint.sh /usr/local/bin/entrypoint

USER www-data
EXPOSE 9000
ENTRYPOINT ["entrypoint"]
CMD ["php-fpm"]

# ---------- web: nginx with public assets only ----------
FROM nginx:stable-alpine AS web
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=runtime /var/www/html/public /var/www/html/public
```

Construir dos imágenes a partir de un solo archivo:

```bash
docker build --target runtime -t registry.example.com/shop:1.42.0 .
docker build --target web     -t registry.example.com/shop-web:1.42.0 .
```

Las etapas, una por una:

* **`base`**: PHP con extensiones. `install-php-extensions` instala por sí solo las bibliotecas del sistema y elimina los compiladores tras compilar la extensión. `php.ini-production` activa los ajustes de producción: los errores no se muestran en la respuesta y `expose_php` está desactivado. En PHP 8.5, OPcache forma parte siempre del núcleo; en PHP 8.4 y anteriores, añada `opcache` a la lista de extensiones.
* **`vendor`**: solo `composer.json` y `composer.lock`, sin el código de la aplicación. `--no-scripts --no-autoloader` hacen falta porque los scripts de Laravel (`package:discover`) necesitan código que en esta etapa todavía no existe. La etapa hereda de `base`, así que Composer comprueba los requisitos de versión de PHP y de extensiones: `--ignore-platform-reqs` no hace falta y es perjudicial.
* **`assets`**: Node.js solo se necesita aquí. Si Tailwind escanea plantillas Blade o clases de paginación de `vendor/`, cópielas también a esta etapa.
* **`runtime`**: la imagen final: código, `vendor/` y assets construidos. `composer dump-autoload --optimize` genera el classmap y ejecuta `package:discover`. Composer se conecta mediante `--mount=type=bind` solo durante ese comando.
* **`web`**: nginx y el contenido de `public/`. No contiene código PHP.

La configuración de nginx para este esquema:

```nginx
# docker/nginx/default.conf
server {
    listen 80;
    root /var/www/html/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known) {
        deny all;
    }
}
```

---

<a id="layer-cache"></a>
## Orden de las capas y caché del build

Docker reconstruye una capa si han cambiado sus datos de entrada, y con ella todas las capas posteriores. De ahí la regla principal: **primero lo que cambia poco, después lo que cambia a menudo**.

* `composer.json` y `composer.lock` se copian por separado del código: editar una plantilla Blade no vuelve a lanzar `composer install`.
* `package.json` y `package-lock.json`: lo mismo para npm.
* `COPY . .` va al final del todo, después de todos los pasos pesados.

El segundo nivel son los **cache mounts de BuildKit**: `RUN --mount=type=cache,target=...`. Incluso cuando se reconstruye la capa de dependencias (ha añadido un paquete), Composer y npm toman los archivos ya descargados de la caché, no de la red. La caché no entra en la imagen.

En CI, la caché se conserva entre ejecuciones con `docker buildx build --cache-to/--cache-from` (por ejemplo, en el registry o en la caché de GitHub Actions). Sin eso, cada build en un runner limpio empieza de cero.

---

<a id="php-config"></a>
## PHP-FPM y OPcache para producción

```ini
; docker/php/conf.d/opcache.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
; Code never changes inside a running container: skip file timestamp checks.
opcache.validate_timestamps=0
```

```ini
; docker/php/conf.d/app.ini
memory_limit=256M
upload_max_filesize=20M
post_max_size=25M
expose_php=Off
```

```ini
; docker/php-fpm/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 = 1000
ping.path = /ping
clear_env = no
```

* **`opcache.validate_timestamps=0`**: en el contenedor el código es inmutable, así que PHP no necesita comprobar la fecha de modificación de los archivos en cada petición. El código nuevo solo llega con un contenedor nuevo.
* **`pm.max_children`** se calcula a partir de la memoria: si el contenedor tiene asignado 1 GB y un worker de PHP-FPM ocupa unos 50 MB, no aguantará más de 15–18 procesos. Mida el consumo real bajo carga.
* **`pm.max_requests`** reinicia el proceso FPM tras N peticiones: un seguro contra fugas de memoria lentas.
* **`clear_env = no`**: por defecto, PHP-FPM limpia las variables de entorno de sus procesos. Si la configuración no está cacheada, la aplicación no las verá.

Los logs de la aplicación en el contenedor deben ir a stdout/stderr, no a un archivo dentro del contenedor: `LOG_CHANNEL=stderr`. Así los recoge Docker o el orquestador, y no se pierden al recrear el contenedor.

---

<a id="config"></a>
## Configuración y secretos

**En la imagen no hay `.env`.** La misma imagen debe arrancar en staging y en producción; la única diferencia son las variables de entorno pasadas al contenedor. Una imagen que contiene un `.env` queda atada a un único entorno y reparte los secretos a cualquiera que tenga acceso al registry.

**Las cachés de Laravel se generan al arrancar el contenedor, no durante el build.** `config:cache` congela los valores actuales de las variables de entorno en `bootstrap/cache/config.php`. Si se ejecuta en el Dockerfile, a la imagen llegan los valores del entorno de build. Con las rutas pasa lo mismo si dependen de la configuración. Por ejemplo, en DevSense la ruta de la clave de IndexNow solo se registra si está definido `config('seo.indexnow_key')`: un `route:cache` durante el build fijaría el conjunto de rutas sin ella.

```sh
#!/bin/sh
# docker/entrypoint.sh
set -e

# Build config, route, view and event caches from the runtime environment.
if [ "${LARAVEL_OPTIMIZE:-true}" = "true" ]; then
    php artisan optimize
fi

# exec replaces the shell, so PHP becomes PID 1 and receives SIGTERM directly.
exec "$@"
```

**Las migraciones son un paso aparte del release, no parte del entrypoint.** Si `migrate --force` se ejecuta al arrancar cada contenedor, diez réplicas lanzarán las migraciones a la vez. Ejecútelas una sola vez antes de cambiar el tráfico: `docker compose run --rm app php artisan migrate --force` o como un job aparte en el orquestador. Cómo escribir migraciones compatibles con el código en ejecución se explica en el artículo sobre [despliegue sin downtime](../architecture/zero-downtime-deployment-laravel#migrations).

**Los secretos en tiempo de build, mediante `--mount=type=secret`.** Si `composer install` necesita el token de un repositorio privado, no lo pase mediante `ARG` ni `ENV`: los valores quedan visibles en el historial de la imagen (`docker history`). BuildKit monta el secreto solo durante un único comando:

```dockerfile
RUN --mount=type=cache,target=/tmp/composer/cache \
    --mount=type=secret,id=composer_auth,target=/tmp/composer/auth.json \
    composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction
```

```bash
docker build --secret id=composer_auth,src=$HOME/.config/composer/auth.json --target runtime .
```

---

<a id="processes"></a>
## Web, workers y scheduler desde una sola imagen

```yaml
# docker-compose.prod.yml
services:
  app:
    image: registry.example.com/shop:${RELEASE}
    env_file: .env.production
    init: true
    restart: unless-stopped

  web:
    image: registry.example.com/shop-web:${RELEASE}
    ports: ["80:80"]
    depends_on: [app]
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1/up"]
      interval: 10s
      timeout: 3s
      retries: 3

  queue:
    image: registry.example.com/shop:${RELEASE}
    env_file: .env.production
    command: ["php", "artisan", "queue:work", "--tries=3", "--timeout=120", "--max-time=3600", "--memory=256"]
    init: true
    stop_grace_period: 180s
    restart: unless-stopped

  scheduler:
    image: registry.example.com/shop:${RELEASE}
    env_file: .env.production
    command: ["php", "artisan", "schedule:work"]
    init: true
    restart: unless-stopped
```

* `command` sobrescribe `CMD`, mientras que el `ENTRYPOINT` con `optimize` se ejecuta en todos los procesos.
* `restart: unless-stopped` sustituye a Supervisor: un `queue:work` que ha terminado por `--max-time` o tras `queue:restart` vuelve a levantarse.
* `init: true` lanza un pequeño proceso init que reenvía las señales y recoge los procesos «zombi».
* `schedule:work` es el scheduler sin cron: el comando lanza `schedule:run` cada minuto. En Kubernetes, en su lugar se suele usar un CronJob con `schedule:run`.

Los parámetros de los workers —timeouts, reintentos, memoria— se analizan en detalle en el artículo sobre [colas en producción](../architecture/laravel-queues-production).

---

<a id="healthchecks"></a>
## Healthcheck y parada ordenada

**Comprobación de salud.** Laravel 11+ incluye la ruta integrada `/up` (el parámetro `health` en `bootstrap/app.php`): responde `200` si la aplicación ha arrancado y `500` si no. La comprobación a través de nginx (`wget` está disponible en la imagen basada en Alpine) verifica toda la cadena nginx → PHP-FPM → Laravel. Para comprobar PHP-FPM por separado existe `ping.path = /ping` en la configuración del pool; se puede consultar con la utilidad `cgi-fcgi` del paquete fcgi. Kubernetes ignora la instrucción `HEALTHCHECK` del Dockerfile y usa sus propias readiness y liveness probes; las mismas URL sirven también para ellas.

**Parada ordenada.** `docker stop` envía `SIGTERM` al proceso principal y, transcurrido `stop_grace_period` (10 segundos por defecto), `SIGKILL`. Lo que hace falta para que la parada sea ordenada:

* el proceso principal es el propio PHP, no un shell: el entrypoint tiene `exec "$@"` y `CMD` está escrito en forma exec (`["php-fpm"]`, no `php-fpm` como cadena);
* el worker de la cola tiene instalada la extensión **pcntl**: `queue:work` recibe `SIGTERM`, termina el job actual y sale;
* `stop_grace_period` es mayor que el job más largo; de lo contrario, Docker matará el worker a mitad del trabajo. Es el mismo principio que `stopwaitsecs` en Supervisor.

---

<a id="security"></a>
## Seguridad de la imagen

* **Sin root.** `USER www-data` al final del Dockerfile. Si alguien encuentra un RCE en la aplicación, el atacante obtendrá los permisos de `www-data` dentro del contenedor, no los de root. PHP-FPM escucha en el puerto 9000 y no necesita privilegios para ello. La advertencia de FPM sobre la directiva `user`, que se ignora sin root, es inofensiva.
* **El mínimo software.** Nada de Xdebug, Node.js, Composer, git ni compiladores en la imagen final. Lo que no está no se puede explotar.
* **Versiones fijadas.** `php:8.5-fpm` cambia con cada release de parche. Para que sea reproducible, fije el digest: `FROM php:8.5-fpm@sha256:...`, y automatice las actualizaciones de las imágenes base con Dependabot o Renovate.
* **Reconstrucción periódica.** Las vulnerabilidades de OpenSSL o glibc se corrigen con una nueva imagen base: aunque su código no haya cambiado, hay que reconstruir la imagen al menos una vez cada una o dos semanas.
* **Escaneo.** `docker scout cves` o `trivy image` en CI detectan vulnerabilidades conocidas en los paquetes del sistema y en las dependencias. Para las dependencias de PHP siguen estando `composer audit` y `npm audit`.
* **Solo lectura.** En el siguiente nivel, el sistema de archivos raíz del contenedor se monta con `read_only: true`, y solo se dejan con escritura `storage/`, `bootstrap/cache` y `/tmp`.

Los ajustes del servidor en torno a los contenedores —cabeceras de seguridad, TLS, límites— se describen en el artículo sobre [hardening de servidores e infraestructura](../security/server-and-infrastructure-hardening).

---

<a id="base-image"></a>
## Alpine, Debian o FrankenPHP

| Opción | Ventajas | Inconvenientes |
|---------|-------|--------|
| `php:8.x-fpm` (Debian) | glibc predecible, paquetes precompilados rápidos, menos sorpresas | Imagen más grande |
| `php:8.x-fpm-alpine` | Tamaño reducido | musl en lugar de glibc: compilación de extensiones más lenta, diferencias en el comportamiento de algunas bibliotecas y en el rendimiento |
| FrankenPHP | Un solo proceso: servidor web Caddy y PHP juntos, HTTPS, modo worker para Laravel Octane | Otro modelo de ejecución: en modo worker el estado vive entre peticiones, y las fugas y los singletons «sucios» pasan a ser su problema |

Para un proyecto Laravel típico, el punto de partida sensato es una imagen Debian con PHP-FPM y nginx, como en este artículo. Alpine tiene sentido cuando el tamaño importa de verdad y ya ha probado la aplicación con musl. Los modelos de ejecución de PHP —FPM, workers, event loop— se comparan en el artículo [PHP en el servidor](../php/runtimes).

---

<a id="common-mistakes"></a>
## Errores frecuentes

**1. La imagen de Sail o una imagen de desarrollo en producción.**
`artisan serve`, Xdebug y Node.js en el entorno de producción: lento e inseguro.

**2. `.env` dentro de la imagen.**
Los secretos están en todas las capas y al alcance de cualquiera que pueda descargar la imagen. La configuración, solo a través del entorno.

**3. `config:cache` en el Dockerfile.**
En la imagen se congelan las variables del entorno de build, y se ignoran los ajustes de producción.

**4. `COPY . .` antes de `composer install`.**
Cualquier cambio en el código invalida la caché de dependencias, y cada build vuelve a descargar todos los paquetes.

**5. `--ignore-platform-reqs` en Composer.**
El build pasa, pero en tiempo de ejecución falta una extensión de PHP. Instale las dependencias con el mismo PHP que en producción.

**6. Secretos mediante `ARG` o `ENV`.**
Los valores se ven en `docker history`. Use `--mount=type=secret`.

**7. `CMD` en forma shell y entrypoint sin `exec`.**
`SIGTERM` lo recibe el shell, no PHP. El worker no se detiene de forma ordenada y lo matan a los 10 segundos a mitad de un job.

**8. `migrate --force` en el entrypoint de cada réplica.**
Varios contenedores lanzan las migraciones a la vez. Las migraciones son un paso aparte del release.

**9. Un proceso que corre como root.**
Cualquier vulnerabilidad en la aplicación da de inmediato permisos de root dentro del contenedor.

---

<a id="checklist"></a>
## Checklist

1. Existe un `.dockerignore`: `.env`, `.git`, `vendor/` y `node_modules/` no entran en el contexto.
2. Build multi-stage: Composer y Node.js solo en las etapas de build.
3. `composer.json`/`composer.lock` y `package*.json` se copian antes que el código; se usan los cache mounts de BuildKit.
4. Las dependencias se instalan con el mismo PHP y las mismas extensiones que en tiempo de ejecución, sin `--ignore-platform-reqs`.
5. `php.ini-production`, OPcache con `validate_timestamps=0`, `pm.max_children` calculado según la memoria.
6. En la imagen no hay `.env` ni secretos; los tokens privados, mediante `--mount=type=secret`.
7. `php artisan optimize` se ejecuta al arrancar el contenedor; las migraciones, como un paso aparte del release.
8. El proceso corre como `www-data`; la imagen base está fijada por digest y se reconstruye periódicamente.
9. web, queue y scheduler se lanzan desde la misma imagen con comandos distintos.
10. Hay healthcheck sobre `/up`, `exec` en el entrypoint, pcntl y un `stop_grace_period` mayor que el job más largo.
11. La imagen se escanea en busca de vulnerabilidades en CI.

---

## Resumen

Una imagen de producción de Laravel no es «Sail más COPY», sino un artefacto independiente: se construye en varias etapas, contiene solo lo necesario para ejecutarse, no sabe nada del entorno concreto y arranca sin root. El orden de las capas y la caché de BuildKit hacen que el build sea rápido, `optimize` al arrancar recoge la configuración real, y una sola imagen para web, workers y scheduler garantiza que todos los procesos ejecutan exactamente el mismo código.

---

<a id="self-test-quiz"></a>
## Cuestionario de autoevaluación

### Pregunta 1: ¿Por qué `composer.json` y `composer.lock` se copian a la imagen por separado y antes que el resto del código?
- A) Composer no sabe leer los archivos copiados con `COPY . .`.
- B) Para que la capa con las dependencias instaladas se reutilice desde la caché mientras no cambien los propios manifiestos, y los cambios en el código no vuelvan a lanzar `composer install`.
- C) Lo exige el formato del build multi-stage.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
Docker reconstruye una capa cuando cambian sus datos de entrada. Si las dependencias se instalan después de `COPY . .`, cualquier cambio en una plantilla invalida la capa con `vendor/`. Copiar los manifiestos por separado desacopla la instalación de dependencias de los cambios en el código.
</details>

### Pregunta 2: El comando `php artisan config:cache` se ejecuta en el Dockerfile. ¿Qué ocurrirá en producción?
- A) Nada especial: Laravel volverá a leer las variables de entorno al arrancar.
- B) La aplicación usará los valores que había en el entorno durante el build e ignorará las variables pasadas al contenedor.
- C) El build fallará, porque `config:cache` no se puede ejecutar sin base de datos.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
La configuración cacheada no vuelve a leer `env()`. La caché debe generarse al arrancar el contenedor, cuando las variables de entorno ya se han pasado; por ejemplo, con `php artisan optimize` en el entrypoint.
</details>

### Pregunta 3: Con `docker stop`, el worker de la cola se corta a mitad de un job. ¿Cuál de estas opciones NO ayudará?
- A) `exec "$@"` en el entrypoint y `CMD` en forma exec, para que `SIGTERM` lo reciba PHP.
- B) Tener instalada la extensión pcntl y un `stop_grace_period` mayor que el job más largo.
- C) Aumentar `memory_limit` en `php.ini`.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: C**
La parada ordenada depende de que la señal llegue al proceso PHP, del manejo de `SIGTERM` (pcntl) y del tiempo que Docker concede antes de `SIGKILL`. El límite de memoria no tiene nada que ver con eso.
</details>