Symfony e Docker em 2026: ambiente de desenvolvimento e deploy em produção

Um fluxo completo de Symfony com Docker para 2026: ambiente de desenvolvimento com FrankenPHP, imagem de produção multi-stage, modo worker, segredos criptografados, otimização de imagem e deploys sem downtime com migrações.

Tutorial Symfony com Docker 2026: ambiente de desenvolvimento e deploy em produção

Este tutorial de Symfony com Docker monta um fluxo de trabalho completo em contêineres: um ambiente de desenvolvimento local reproduzível e uma imagem de produção endurecida, ambos centrados no FrankenPHP e no Symfony 7.4 LTS. Rodar o Symfony em contêineres elimina a clássica divergência de ambiente entre notebooks e servidores, e transforma cada implantação no envio de um único artefato imutável, em vez de executar uma sequência frágil de comandos manuais no servidor.

A stack Symfony com Docker em 2026

O template oficial Symfony Docker agora entrega o FrankenPHP como runtime, substituindo a dupla tradicional Nginx mais PHP-FPM. Um único binário serve HTTP/2 e HTTP/3 e executa o Symfony em modo worker persistente, mantendo o kernel inicializado entre as requisições em vez de reconstruí-lo a cada chamada.

Ambiente de desenvolvimento com Docker Compose para Symfony

Uma boa configuração de desenvolvimento garante a cada colaborador a mesma versão do PHP, as mesmas extensões e o mesmo banco de dados sem tocar na máquina hospedeira. O Docker Compose descreve essa stack de forma declarativa. O exemplo abaixo combina um contêiner FrankenPHP construído a partir de um Dockerfile local com um serviço PostgreSQL 17, conectados na rede padrão do Compose.

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:

O bind mount no serviço php mapeia a raiz do projeto para dentro do contêiner, então as alterações de arquivos entram em vigor sem reconstruir a imagem. O FrankenPHP gera um certificado TLS local na primeira inicialização, e é por isso que a porta 443 fica exposta e caddy_data é um volume nomeado: o certificado sobrevive ao docker compose down. A dica serverVersion=17 no DSN permite ao Doctrine pular uma consulta de detecção de versão a cada conexão.

Com a stack no ar, todo comando do Symfony é executado dentro do contêiner php, e não no host, o que garante a versão e as extensões corretas do PHP. Criar um banco, rodar migrações ou abrir um shell seguem o mesmo padrão através do docker compose exec.

Executando comandos do console no contêiner

Prefixe as chamadas ao Symfony e ao Composer com docker compose exec php para executá-las contra o runtime PHP do contêiner, por exemplo docker compose exec php bin/console make:entity ou docker compose exec php composer require symfony/uid. O host nunca precisa ter o PHP instalado.

Dockerfile multi-stage para produção com Symfony

Um Dockerfile multi-stage compartilha uma base comum e então se divide em um alvo de desenvolvimento e um alvo de produção. O estágio de produção instala apenas as dependências de runtime, descarta os pacotes de desenvolvimento do Composer e aquece o cache em tempo de build para que o contêiner em execução inicie instantaneamente.

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 o composer.json e os arquivos de lock antes do restante do código-fonte é a otimização central: o Docker mantém em cache a camada de instalação de dependências e só a reexecuta quando os manifestos mudam, não a cada edição de código. A flag --classmap-authoritative informa ao Composer que o classmap está completo, de modo que o autoloader nunca recorre a buscas no sistema de arquivos em runtime. Aquecer o cache durante o build faz com que a primeira requisição de produção chegue a um contêiner totalmente compilado.

Modo worker do FrankenPHP e o Runtime do Symfony

O PHP-FPM tradicional inicializa o kernel do Symfony, trata uma requisição e descarta tudo. O modo worker mantém o kernel e o contêiner de injeção de dependências vivos entre as requisições, e é daí que vem a maior parte da economia de latência. O pacote runtime/frankenphp-symfony viabiliza isso através do componente Symfony Runtime e do bootstrap padrão public/index.php.

public/index.phpphp
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']);
};

A linha FRANKENPHP_CONFIG="worker ./public/index.php" definida no Dockerfile ativa esse loop. Como o contêiner é reutilizado, qualquer serviço que guarde estado específico da requisição precisa se redefinir entre as chamadas. O Symfony trata os serviços do framework automaticamente, mas serviços customizados com estado devem implementar Symfony\Contracts\Service\ResetInterface e receber a tag kernel.reset, para que o estado acumulado não vaze para a próxima requisição. Essa é a mesma disciplina exigida pelos workers do Messenger de longa duração, e o ganho é uma vazão de requisições várias vezes maior que a do PHP-FPM no mesmo hardware.

Para o desenvolvimento local, rodar o FrankenPHP com a flag --watch reinicia o worker automaticamente quando os arquivos mudam, de modo que o modo worker não atrapalha o ciclo de edição e recarga.

FrankenPHP versus PHP-FPM no Symfony

A escolha entre o FrankenPHP e a stack clássica Nginx mais PHP-FPM molda tanto o Dockerfile quanto o comportamento em runtime. A tabela abaixo resume as diferenças práticas para uma configuração de produção com Symfony.

| Aspecto | FrankenPHP em modo worker | Nginx + PHP-FPM | |--------|------------------------|-----------------| | Contêineres | Um | Dois (servidor web + PHP) | | Boot do kernel | Uma vez por worker | Uma vez por requisição | | HTTP/3 | Nativo | Exige configuração extra | | TLS | Automático (Caddy) | Configuração manual de certificado | | Gestão de estado | Precisa redefinir entre requisições | Isolado por requisição | | Custo de cold-start | Pago uma vez no boot | Pago a cada requisição |

O modo worker vence em vazão porque amortiza a cara etapa de boot do kernel e de compilação do contêiner ao longo de milhares de requisições. O PHP-FPM continua relevante quando uma aplicação depende de globais ou de bibliotecas de terceiros que assumem um processo novo por requisição, já que essas quebram sob um worker reutilizado.

Vazamento de estado no modo worker

Um serviço que armazena dados de requisição em uma propriedade privada servirá os dados de uma requisição para a seguinte a menos que se redefina. Audite singletons, event subscribers e qualquer coisa que segure um token de segurança ou a requisição atual antes de migrar para o modo worker em produção.

Pronto para mandar bem nas entrevistas de Symfony?

Pratique com nossos simuladores interativos, flashcards e testes tecnicos.

Gerenciando variáveis de ambiente e segredos em produção

O Symfony lê a configuração a partir de variáveis de ambiente, e há duas maneiras sólidas de fornecê-las em produção. A primeira é injetar variáveis simples através do orquestrador ou do arquivo Compose. A segunda é o cofre de segredos criptografados do Symfony, que armazena valores sensíveis no repositório como texto cifrado e os descriptografa em runtime com uma única chave 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)"

A etapa composer dump-env prod no Dockerfile compila os arquivos .env em um único .env.local.php otimizado, o que remove o custo de runtime de fazer o parse dos arquivos dotenv no boot. Qualquer variável definida no ambiente real do contêiner ainda sobrepõe os padrões compilados, então os segredos fornecidos pelo orquestrador sempre prevalecem. O guia de implantação do Symfony documenta a ordem completa de precedência, mas a regra prática é simples: nunca embuta APP_SECRET ou credenciais de banco na imagem, e injete-os no início do contêiner.

Otimizando a imagem de produção do Symfony

Duas configurações do OPcache respondem pela maior parte da diferença de desempenho em produção: desativar a validação de timestamps e habilitar o preloading. Como uma imagem de contêiner é imutável, o código-fonte nunca muda em runtime, então o OPcache nunca deveria consultar arquivos para checar edições. O preloading vai além, carregando as classes do Symfony e da aplicação na memória compartilhada uma única vez na inicialização do 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

O Symfony gera o arquivo preload.php listado acima durante o cache:warmup, então o arquivo já existe na imagem após o build. O preloading do OPcache vincula essas classes na inicialização e pula a etapa de compilação na primeira requisição que precisar delas. Definir validate_timestamps=0 só é seguro para implantações imutáveis, em que uma nova release significa uma nova imagem; em um host mutável isso serviria código desatualizado. Dimensionar max_accelerated_files acima da contagem real de arquivos mantém todo o framework em cache sem despejo.

Reduzindo o tamanho da imagem Symfony com .dockerignore

A ausência de um arquivo .dockerignore incha o build silenciosamente. Sem ele, a etapa COPY . . envia o diretório vendor local, o var/cache do desenvolvimento, a saída de build do node e o histórico do .git direto para dentro da imagem e do contexto de build. Excluí-los encolhe a imagem, acelera o upload do contexto de build para o daemon e evita que artefatos de desenvolvimento sobrescrevam as dependências de produção recém-instaladas.

gitignore
# .dockerignore
/.git/
/vendor/
/node_modules/
/var/
/.env.local
/.env.*.local
/tests/
/docker/
compose*.yaml
Dockerfile

Ignorar /vendor/ é o que mais importa: o estágio de produção roda composer install --no-dev, então enviar o diretório vendor de desenvolvimento do host anularia completamente essa etapa. Excluir /var/ mantém o cache e os logs de desenvolvimento fora da imagem, permitindo que o cache:warmup em tempo de build produza um cache de produção limpo. Combinada com o build multi-stage e as extensões baseadas em Alpine, uma imagem Symfony enxuta costuma ficar bem abaixo de 150 MB.

Implantando contêineres Symfony e executando migrações

O arquivo Compose de produção referencia uma imagem pré-construída de um registry, em vez de construir localmente, e define um health check para que o orquestrador só direcione tráfego a um contêiner que responde. Os segredos chegam como variáveis de ambiente, nunca como camadas da imagem.

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

O health check faz um curl em uma rota /health, então a aplicação precisa expor uma. Um controller mínimo que retorna 200 sem tocar no banco de dados mantém a verificação rápida e evita marcar o contêiner como não saudável durante uma instabilidade transitória do banco. Quando a prontidão do banco importa, uma segunda sonda mais profunda pode rodar uma consulta leve, mas o endpoint de liveness continua trivial.

src/Controller/HealthController.phpphp
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']);
    }
}

As migrações de banco de dados devem rodar como uma etapa distinta antes de os novos contêineres receberem tráfego, e não dentro do boot da aplicação. Executá-las em um contêiner efêmero garante que o esquema fique atualizado exatamente uma vez por release, mesmo quando várias réplicas da aplicação iniciam em 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

A flag --wait bloqueia até que todo serviço reporte estado saudável, dando ao script de deploy um sinal de sucesso real em vez de um início do tipo "dispare e esqueça". Combinar isso com uma atualização gradual em um orquestrador como Kubernetes ou Docker Swarm resulta em releases sem downtime: os contêineres antigos continuam servindo até os novos passarem no health check. Para uma visão mais ampla das versões do framework que esse fluxo mira, o panorama da plataforma Symfony e o guia de recursos do Symfony 8 e PHP 8.4 cobrem o que mudou na linha de release atual.

Conclusão

  • Use um Dockerfile multi-stage para que as imagens de desenvolvimento e de produção compartilhem uma base enquanto o alvo de produção é entregue sem as dependências de desenvolvimento do Composer
  • Copie o composer.json e os arquivos de lock antes do código-fonte para manter a camada de instalação de dependências em cache através das mudanças de código
  • Rode o FrankenPHP em modo worker para reutilizar o kernel inicializado entre as requisições, e redefina serviços com estado usando ResetInterface para evitar vazamentos de estado
  • Aqueça o cache do Symfony e compile o .env com dump-env prod em tempo de build para que o contêiner em execução inicie totalmente inicializado
  • Habilite o preloading do OPcache e defina validate_timestamps=0 em produção, o que é seguro justamente porque a imagem é imutável
  • Mantenha APP_SECRET, as credenciais de banco e a chave de descriptografia do cofre fora das camadas da imagem; injete-os como variáveis de ambiente no início do contêiner
  • Aplique as migrações do Doctrine em um contêiner efêmero antes de as novas réplicas receberem tráfego, e condicione o rollout ao health check com up -d --wait

Comece a praticar!

Teste seus conhecimentos com nossos simuladores de entrevista e testes tecnicos.

Tags

#symfony
#docker
#frankenphp
#php
#deployment
#devops

Compartilhar

Artigos relacionados