# Symfony y Docker en 2026: entorno de desarrollo y despliegue en producción > Un flujo completo de Symfony con Docker para 2026: un entorno de desarrollo con FrankenPHP, una imagen de producción multi-etapa, modo worker, secretos cifrados, optimización de la imagen y despliegues sin tiempo de inactividad con migraciones. - Published: 2026-06-25 - Updated: 2026-07-06 - Author: SharpSkill - Tags: symfony, docker, frankenphp, php, deployment, devops - Reading time: 10 min --- Este tutorial de Symfony con Docker construye un flujo de contenedores completo: un entorno de desarrollo local reproducible y una imagen de producción reforzada, ambos centrados en FrankenPHP y Symfony 7.4 LTS. Ejecutar Symfony dentro de contenedores elimina la clásica brecha de "deriva de entornos" entre las laptops y los servidores, y convierte cada despliegue en el envío de un único artefacto inmutable en lugar de una frágil secuencia de comandos manuales en el servidor. > **El stack Symfony Docker de 2026** > > La plantilla oficial de Symfony Docker ahora incluye [FrankenPHP](https://frankenphp.dev/) como runtime, reemplazando el tradicional par Nginx más PHP-FPM. Un solo binario sirve HTTP/2 y HTTP/3, y ejecuta Symfony en modo worker persistente manteniendo el kernel arrancado entre peticiones en lugar de reconstruirlo en cada llamada. ## Entorno de desarrollo con Docker Compose para Symfony Una buena configuración de desarrollo entrega a cada colaborador la misma versión de PHP, las mismas extensiones y la misma base de datos sin tocar la máquina anfitriona. Docker Compose describe ese stack de forma declarativa. El ejemplo siguiente combina un contenedor FrankenPHP construido a partir de un `Dockerfile` local con un servicio PostgreSQL 17, conectados a través de la red de Compose por defecto. ```yaml # compose.yaml services: php: build: context: . target: frankenphp_dev ports: - "80:80" - "443:443" volumes: - ./:/app # bind mount: edits on the host appear instantly - caddy_data:/data # persist TLS certificates across restarts environment: DATABASE_URL: "postgresql://app:app@database:5432/app?serverVersion=17" depends_on: - database database: image: postgres:17-alpine environment: POSTGRES_DB: app POSTGRES_USER: app POSTGRES_PASSWORD: app volumes: - db_data:/var/lib/postgresql/data volumes: caddy_data: db_data: ``` El bind mount del servicio `php` mapea la raíz del proyecto dentro del contenedor, de modo que los cambios en los archivos surten efecto sin reconstruir la imagen. FrankenPHP genera un certificado TLS local en el primer arranque, y por eso el puerto 443 queda expuesto y `caddy_data` es un volumen con nombre: el certificado sobrevive a `docker compose down`. La pista `serverVersion=17` en el DSN permite que Doctrine se salte una consulta de detección de versión en cada conexión. Una vez que el stack está en marcha, cada comando de Symfony se ejecuta dentro del contenedor `php` y no en el anfitrión, lo que garantiza la versión y las extensiones de PHP correctas. Crear una base de datos, aplicar migraciones o abrir una terminal siguen todos el mismo patrón mediante `docker compose exec`. > **Ejecutar comandos de consola en el contenedor** > > Antepón `docker compose exec php` a las llamadas de Symfony y Composer para ejecutarlas contra el runtime de PHP del contenedor, por ejemplo `docker compose exec php bin/console make:entity` o `docker compose exec php composer require symfony/uid`. El anfitrión nunca necesita tener PHP instalado. ## Dockerfile multi-etapa para producción con Symfony Un [Dockerfile multi-etapa](https://docs.docker.com/build/building/multi-stage/) comparte una base común y luego se divide en un objetivo de desarrollo y un objetivo de producción. La etapa de producción instala solo las dependencias de runtime, descarta los paquetes de desarrollo de Composer y precalienta la caché en tiempo de construcción para que el contenedor en ejecución arranque al instante. ```dockerfile # Dockerfile FROM dunglas/frankenphp:1-php8.4 AS base WORKDIR /app # Install the PHP extensions Symfony relies on RUN install-php-extensions \ intl \ opcache \ pdo_pgsql \ zip COPY --from=composer/composer:2-bin /composer /usr/bin/composer # --- Development target --- FROM base AS frankenphp_dev ENV APP_ENV=dev RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" # --- Production target --- FROM base AS frankenphp_prod ENV APP_ENV=prod ENV FRANKENPHP_CONFIG="worker ./public/index.php" ENV APP_RUNTIME="Runtime\FrankenPhpSymfony\Runtime" RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" # Layer caching: copy manifests first, install, then copy the source COPY composer.json composer.lock symfony.lock ./ RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist COPY . . RUN composer dump-autoload --no-dev --classmap-authoritative \ && composer dump-env prod \ && bin/console cache:warmup ``` Copiar `composer.json` y los archivos de lock antes que el resto del código fuente es la optimización clave: Docker cachea la capa de instalación de dependencias y solo la vuelve a ejecutar cuando cambian los manifiestos, no en cada edición de código. El flag `--classmap-authoritative` le indica a Composer que el classmap está completo, de modo que el autoloader nunca recurre a búsquedas en el sistema de archivos en tiempo de ejecución. Precalentar la caché durante la construcción significa que la primera petición de producción llega a un contenedor totalmente compilado. ## El modo worker de FrankenPHP y el Runtime de Symfony El PHP-FPM tradicional arranca el kernel de Symfony, atiende una petición y descarta todo. El modo worker mantiene vivos el kernel y el contenedor de inyección de dependencias a lo largo de las peticiones, y de ahí proviene la mayor parte del ahorro de latencia. El paquete `runtime/frankenphp-symfony` hace posible esto a través del componente Symfony Runtime y el arranque estándar `public/index.php`. ```php // public/index.php use App\Kernel; require_once dirname(__DIR__).'/vendor/autoload_runtime.php'; return function (array $context) { return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); }; ``` La línea `FRANKENPHP_CONFIG="worker ./public/index.php"` definida en el Dockerfile activa este bucle. Como el contenedor se reutiliza, cualquier servicio que retenga estado específico de una petición debe restablecerse entre peticiones. Symfony gestiona automáticamente los servicios del framework, pero los servicios personalizados con estado deberían implementar `Symfony\Contracts\Service\ResetInterface` y etiquetarse con `kernel.reset` para que el estado acumulado no se filtre a la siguiente petición. Es la misma disciplina que exigen los [workers de Messenger](/blog/symfony/symfony-messenger-queues-workers-async-architecture) de larga duración, y la recompensa es un rendimiento de peticiones varias veces superior al de PHP-FPM sobre el mismo hardware. Para el desarrollo local, ejecutar FrankenPHP con el flag `--watch` reinicia el worker automáticamente cuando cambian los archivos, de modo que el modo worker no estorba el ciclo de editar y refrescar. ## FrankenPHP frente a PHP-FPM en Symfony La elección entre FrankenPHP y el clásico stack Nginx más PHP-FPM condiciona tanto el Dockerfile como el comportamiento en ejecución. La tabla siguiente resume las diferencias prácticas para una configuración de producción con Symfony. | Aspecto | FrankenPHP modo worker | Nginx + PHP-FPM | |--------|------------------------|-----------------| | Contenedores | Uno | Dos (servidor web + PHP) | | Arranque del kernel | Una vez por worker | Una vez por petición | | HTTP/3 | Integrado | Requiere configuración extra | | TLS | Automático (Caddy) | Configuración manual del certificado | | Manejo del estado | Debe restablecerse entre peticiones | Aislado por petición | | Costo de arranque en frío | Se paga una vez al arrancar | Se paga en cada petición | El modo worker gana en rendimiento porque amortiza el costoso paso de arranque del kernel y compilación del contenedor a lo largo de miles de peticiones. PHP-FPM sigue siendo relevante cuando una aplicación depende de variables globales o de librerías de terceros que asumen un proceso nuevo por petición, ya que estas se rompen bajo un worker reutilizado. > **Fugas de estado en modo worker** > > Un servicio que cachea datos de una petición en una propiedad privada servirá los datos de una petición a la siguiente a menos que se restablezca. Audita los singletons, los suscriptores de eventos y cualquier cosa que retenga un token de seguridad o la petición actual antes de cambiar al modo worker en producción. ## Gestión de variables de entorno y secretos en producción Symfony lee la configuración desde variables de entorno, y existen dos formas sólidas de suministrarlas en producción. La primera es inyectar variables en texto plano a través del orquestador o del archivo Compose. La segunda es el almacén de secretos cifrados de Symfony, que guarda los valores sensibles en el repositorio como texto cifrado y los descifra en tiempo de ejecución con una única clave privada. ```bash # Generate the keypair; commit the public key, keep the private key out of the image php bin/console secrets:generate-keys # Encrypt a value into config/secrets/prod/ php bin/console secrets:set DATABASE_URL # In production, expose only the decryption key to the container export SYMFONY_DECRYPTION_SECRET="$(cat config/secrets/prod/prod.decrypt.private.php)" ``` El paso `composer dump-env prod` del Dockerfile compila los archivos `.env` en un único `.env.local.php` optimizado, lo que elimina el costo en tiempo de ejecución de parsear archivos dotenv en el arranque. Cualquier variable definida en el entorno real del contenedor sigue teniendo prioridad sobre los valores por defecto compilados, así que los secretos provistos por el orquestador siempre prevalecen. La [guía de despliegue de Symfony](https://symfony.com/doc/current/deployment.html) documenta el orden de precedencia completo, pero la regla práctica es sencilla: nunca incrustes `APP_SECRET` ni las credenciales de la base de datos en la imagen, e inyéctalos al arrancar el contenedor. ## Optimización de la imagen de producción de Symfony Dos ajustes de OPcache explican la mayor parte de la brecha de rendimiento en producción: desactivar la validación de timestamps y habilitar el preloading. Como una imagen de contenedor es inmutable, el código fuente nunca cambia en tiempo de ejecución, por lo que OPcache nunca debería consultar los archivos para comprobar ediciones. El preloading va más allá al cargar las clases de Symfony y de la aplicación en memoria compartida una sola vez al arrancar el servidor. ```ini ; docker/php/prod.ini opcache.enable=1 opcache.preload=/app/var/cache/prod/App_KernelProdContainer.preload.php opcache.preload_user=root opcache.memory_consumption=256 opcache.max_accelerated_files=20000 opcache.validate_timestamps=0 ; immutable image: never re-check the filesystem realpath_cache_size=4096K realpath_cache_ttl=600 ``` Symfony genera el archivo `preload.php` indicado arriba durante `cache:warmup`, de modo que el archivo ya existe en la imagen tras la construcción. El [preloading de OPcache](https://www.php.net/manual/en/opcache.preloading.php) enlaza estas clases al arrancar y omite el paso de compilación en la primera petición que las necesita. Fijar `validate_timestamps=0` solo es seguro en despliegues inmutables, donde una nueva versión implica una nueva imagen; en un anfitrión mutable serviría código obsoleto. Dimensionar `max_accelerated_files` por encima del número real de archivos mantiene todo el framework en caché sin expulsiones. ## Reducción del tamaño de la imagen de Symfony con .dockerignore La ausencia de un archivo `.dockerignore` infla la construcción de forma silenciosa. Sin él, el paso `COPY . .` envía a la imagen y al contexto de construcción el directorio `vendor` local, la `var/cache` del desarrollo, la salida de compilación de node y el historial `.git` completo. Excluirlos reduce el tamaño de la imagen, acelera la subida del contexto de construcción al daemon y evita que los artefactos de desarrollo sobrescriban las dependencias de producción recién instaladas. ```gitignore # .dockerignore /.git/ /vendor/ /node_modules/ /var/ /.env.local /.env.*.local /tests/ /docker/ compose*.yaml Dockerfile ``` Ignorar `/vendor/` es lo que más importa: la etapa de producción ejecuta `composer install --no-dev`, así que enviar el directorio vendor del anfitrión, con sabor a desarrollo, anularía por completo ese paso. Excluir `/var/` mantiene la caché y los logs de desarrollo fuera de la imagen, permitiendo que el `cache:warmup` de la construcción produzca una caché de producción limpia. Combinada con la construcción multi-etapa y las extensiones basadas en Alpine, una imagen ligera de Symfony suele quedar bastante por debajo de los 150 MB. ## Despliegue de contenedores Symfony y ejecución de migraciones El archivo Compose de producción referencia una imagen preconstruida desde un registro en lugar de construirla localmente, y define un health check para que el orquestador solo enrute tráfico hacia un contenedor que responde. Los secretos llegan como variables de entorno, nunca como capas de la imagen. ```yaml # compose.prod.yaml services: php: image: registry.example.com/app:${TAG} environment: APP_ENV: prod APP_SECRET: ${APP_SECRET} DATABASE_URL: ${DATABASE_URL} healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost/health"] interval: 10s timeout: 3s retries: 3 restart: unless-stopped ``` El health check hace un curl a una ruta `/health`, así que la aplicación tiene que exponer una. Un controlador mínimo que devuelve un 200 sin tocar la base de datos mantiene la comprobación rápida y evita marcar el contenedor como no saludable durante un fallo transitorio de la base de datos. Cuando importa la disponibilidad de la base de datos, una segunda sonda más profunda puede ejecutar una consulta ligera, pero el endpoint de liveness se mantiene trivial. ```php // src/Controller/HealthController.php namespace App\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; final class HealthController extends AbstractController { #[Route('/health', name: 'health', methods: ['GET'])] public function __invoke(): JsonResponse { return new JsonResponse(['status' => 'ok']); } } ``` Las migraciones de la base de datos deberían ejecutarse como un paso discreto antes de que los nuevos contenedores reciban tráfico, no dentro del arranque de la aplicación. Ejecutarlas en un contenedor de un solo uso garantiza que el esquema quede actualizado exactamente una vez por versión, incluso cuando varias réplicas de la aplicación arrancan en paralelo. ```bash # deploy.sh set -euo pipefail docker compose -f compose.prod.yaml pull # Apply migrations once, in an isolated container docker compose -f compose.prod.yaml run --rm php \ bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration # Start replicas and wait for the health check to pass docker compose -f compose.prod.yaml up -d --wait ``` El flag `--wait` bloquea hasta que cada servicio reporta estar saludable, dándole al script de despliegue una señal de éxito real en lugar de un arranque a ciegas. Combinar esto con una actualización progresiva en un orquestador como Kubernetes o Docker Swarm produce versiones sin tiempo de inactividad: los contenedores antiguos siguen sirviendo hasta que los nuevos pasan su health check. Para una visión más amplia de las versiones del framework a las que apunta este flujo, la [descripción de la plataforma Symfony](/technologies/symfony) y la [guía de novedades de Symfony 8 y PHP 8.4](/blog/symfony/symfony-8-new-features-php-84-lazy-objects) cubren qué cambió en la línea de versiones actual. ## Conclusión - Usa un Dockerfile multi-etapa para que las imágenes de desarrollo y producción compartan una única base mientras el objetivo de producción se envía sin las dependencias de desarrollo de Composer - Copia `composer.json` y los archivos de lock antes que el código fuente para mantener la capa de instalación de dependencias en caché a lo largo de los cambios de código - Ejecuta FrankenPHP en modo worker para reutilizar el kernel arrancado a lo largo de las peticiones, y restablece los servicios con estado mediante `ResetInterface` para evitar fugas de estado - Precalienta la caché de Symfony y compila `.env` con `dump-env prod` en tiempo de construcción para que el contenedor en ejecución arranque totalmente inicializado - Habilita el preloading de OPcache y fija `validate_timestamps=0` en producción, lo cual es seguro precisamente porque la imagen es inmutable - Mantén `APP_SECRET`, las credenciales de la base de datos y la clave de descifrado del almacén fuera de las capas de la imagen; inyéctalas como variables de entorno al arrancar el contenedor - Aplica las migraciones de Doctrine en un contenedor de un solo uso antes de que las nuevas réplicas reciban tráfico, y condiciona el despliegue al health check con `up -d --wait` --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/es/blog/symfony/symfony-docker-development-production