Compose
Uma aplicação inteira descrita em um único arquivo.
Docker Compose
Uma aplicação real quase nunca é um único contêiner. É uma API, um banco de dados, um cache, às vezes um worker assíncrono e um proxy na frente. Cinco contêineres, portanto cinco docker run — com as portas certas, as variáveis certas, a rede certa, na ordem certa, e lembrando de tudo isso amanhã de manhã.
Isso se sustenta por um tempo, à base de scripts shell copiados de um projeto para outro. Depois a situação sempre se degrada da mesma maneira: o script do colega não usa a mesma versão do Postgres, ninguém mais sabe por que o worker carrega aquela opção obscura, a chegada de um novo desenvolvedor custa meio dia, e a senha do banco fica perdida no histórico de três terminais. O problema não é o número de comandos. É que não existe nenhuma descrição única e verificável do que « a aplicação » significa.
O Docker Compose responde exatamente a essa lacuna. Um arquivo YAML declara o conjunto de serviços, suas imagens, suas variáveis, seus volumes, suas dependências. Esse arquivo vive no repositório Git, ao lado do código. Clonar o projeto e digitar um único comando basta para obter a stack completa, idêntica para todos — e o git log explica por que determinada opção foi adicionada.
Uma palavra sobre a sintaxe antes de começar, porque ela é fonte de confusão. A forma moderna é docker compose, em duas palavras: é um plugin integrado ao cliente Docker. A forma antiga docker-compose, com hífen, é um binário Python separado, a « v1 », cujo desenvolvimento foi encerrado em 2023. As duas se parecem, mas a v1 ignora parte da sintaxe atual. Tudo o que segue usa docker compose.
Um primeiro arquivo, serviço por serviço
O arquivo se chama compose.yaml e fica na raiz do projeto. É o nome recomendado pela especificação Compose; docker-compose.yml continua reconhecido por compatibilidade, e você o encontrará com frequência em projetos existentes.
# compose.yaml
# Sem a chave "version:" no topo: ela é obsoleta e não tem efeito.
services:
# ---------- A aplicação ----------
api:
build:
context: .
dockerfile: Dockerfile
image: mon-api:dev # nome atribuído à imagem construída
ports:
- "3000:3000" # host:contêiner
environment:
NODE_ENV: development
# "db" e "cache" são nomes de SERVIÇOS: eles são resolvidos
# via DNS na rede da stack. Nenhum endereço IP para escrever.
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/appdb
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
restart: unless-stopped
# ---------- O banco de dados ----------
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: appdb
volumes:
# Volume nomeado: os dados sobrevivem à remoção do contêiner
- db-data:/var/lib/postgresql/data
healthcheck:
# O $$ duplo escapa a interpolação do Compose: a variável deve
# ser resolvida DENTRO do contêiner, não pelo Compose.
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
restart: unless-stopped
# ---------- O cache ----------
cache:
image: redis:7-alpine
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
volumes:
- cache-data:/data
restart: unless-stopped
volumes:
db-data:
cache-data:
Dois detalhes deste arquivo merecem ser destacados já.
O banco de dados não publica nenhuma porta: não há chave ports em db. Ainda assim, ele é perfeitamente acessível pela API no endereço db:5432, porque os dois serviços compartilham uma rede interna criada automaticamente pelo Compose. Publicar 5432:5432 exporia seu banco em todas as interfaces da máquina, o que só é necessário para conectar um cliente gráfico a partir do host.
E os serviços se referem uns aos outros pelo nome, nunca por um endereço IP. Os IPs dos contêineres mudam a cada recriação; os nomes, não. A próxima lição detalha esse mecanismo de rede.
O ciclo de comandos do dia a dia
O Compose funciona por convergência: você descreve o estado desejado, e ele calcula as ações a executar. O up não reinicia tudo, ele só mexe no que mudou.
docker compose up -d # iniciar em segundo plano
docker compose up -d --build # reconstruir as imagens locais antes
docker compose up -d --wait # aguardar os healthchecks ficarem verdes
docker compose ps # estado da stack, com a saúde
docker compose logs -f # logs agregados, em tempo real
docker compose logs -f --tail 100 api # um único serviço
# Abrir um shell em um serviço que está RODANDO
docker compose exec api sh
docker compose exec db psql -U app -d appdb
# Executar um comando em um contêiner DESCARTÁVEL.
# run cria um novo contêiner, exec entra no que está rodando.
docker compose run --rm api npm run migrate
docker compose restart api
Na parada, vale distinguir com cuidado três comandos, porque um deles destrói dados:
# Parar sem remover nada. Reversível com "start".
docker compose stop
# Parar E remover contêineres e redes.
# Os VOLUMES são mantidos: seus dados sobrevivem.
docker compose down
# Idem, mais a remoção dos volumes. Seus dados são PERDIDOS.
docker compose down -v
# Remover também os contêineres órfãos de uma versão antiga do arquivo
docker compose down --remove-orphans
Por fim, o comando de diagnóstico mais rentável:
# Exibe o arquivo como o Compose o entende DE FATO: variáveis
# interpoladas, arquivos mesclados, valores padrão aplicados.
# O primeiro reflexo quando uma variável "não passa".
docker compose config
Uma palavra sobre o nome do projeto. O Compose prefixa todos os objetos que cria com um nome derivado do diretório atual: o volume db-data vira monprojet_db-data. É isso que permite rodar duas stacks independentes na mesma máquina, e também é o que explica por que renomear uma pasta aparentemente « faz perder » os dados — eles continuam lá, sob o prefixo antigo.
docker compose -p mon-projet up -d
# ou, de forma persistente, no .env: COMPOSE_PROJECT_NAME=mon-projet
image ou build: consumir ou produzir
image sozinho puxa uma imagem existente de um registry: é o caso de Postgres, Redis ou Nginx, que você nunca constrói. build constrói a partir de um Dockerfile: é o caso do seu código. Combinar as duas chaves dá um nome à imagem construída, o que a torna enviável a um registry.
services:
api:
build:
context: . # como o "." do docker build
dockerfile: docker/Dockerfile.api # relativo ao contexto
target: runtime # estágio de um Dockerfile multi-stage
args: # equivale a --build-arg
NODE_VERSION: "22"
image: registre.exemple.com/equipe/mon-api:dev
A armadilha de iniciante está aqui: up não reconstrói uma imagem existente. Você modifica o Dockerfile, executa up -d de novo, e nada muda. O Compose reutiliza a imagem já construída; é preciso pedir explicitamente.
docker compose build api # reconstruir um serviço
docker compose build --no-cache api # sem cache
docker compose up -d --build # reconstruir e depois iniciar
As dependências: depends_on sozinho não basta
É a fonte de erro mais frequente e mais desconcertante, porque o sintoma é intermitente.
A forma curta de depends_on garante apenas uma ordem de inicialização. O Compose espera o contêiner db ser iniciado, não que o Postgres aceite conexões. Ora, o Postgres leva de dois a dez segundos para inicializar seu cluster na primeira vez. Sua API sobe nesse intervalo, falha com connection refused e encerra. Em uma máquina rápida às vezes passa: daí a impressão de um bug aleatório.
# Insuficiente: garante a ORDEM, não a DISPONIBILIDADE
services:
api:
depends_on:
- db
A solução correta associa um healthcheck no serviço do qual se depende e uma condição no serviço dependente:
services:
api:
depends_on:
db:
condition: service_healthy # aguarda o healthcheck ficar verde
cache:
condition: service_started # a inicialização basta
migrate:
condition: service_completed_successfully # aguarda uma saída com código 0
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s # frequência das verificações
timeout: 3s # além disso, a verificação é uma falha
retries: 10 # falhas consecutivas antes do status "unhealthy"
start_period: 10s # período de carência: as falhas não são contadas
migrate:
build: .
command: ["npm", "run", "migrate"]
depends_on:
db:
condition: service_healthy
restart: "no" # um trabalho pontual, não um serviço
A terceira condição, service_completed_successfully, resolve com elegância o problema das migrations de schema: o serviço migrate executa uma vez, sai com código 0, e só então a API inicia. Chega de scripts de espera improvisados.
# A coluna STATUS exibe (healthy), (unhealthy) ou (health: starting)
docker compose ps
# Histórico detalhado das verificações, com as saídas dos comandos
docker inspect --format '{{json .State.Health}}' $(docker compose ps -q db)
Uma ressalva honesta: depends_on só atua na inicialização. Se o banco reiniciar durante a execução, sua aplicação precisa saber se reconectar. Uma lógica de reconexão com backoff progressivo no código continua indispensável; depends_on elimina o ruído na inicialização, mas não substitui a robustez.
Variáveis de ambiente: três mecanismos distintos
A confusão vem do fato de que três coisas diferentes têm quase o mesmo nome.
O arquivo .env ao lado do compose.yaml alimenta a interpolação no próprio YAML: seus valores substituem os ${...} antes que o Compose processe qualquer coisa. A chave environment define as variáveis recebidas pelo processo dentro do contêiner. A chave env_file carrega um arquivo de variáveis diretamente no contêiner, sem passar pela interpolação.
# .env -- a excluir do Git via .gitignore
DB_PASSWORD=motdepasse-de-dev
API_PORT=3000
TAG=dev
services:
api:
image: mon-api:${TAG} # interpolado ANTES da inicialização
ports:
- "${API_PORT}:3000"
env_file:
- ./config/api.env # injetado como está no contêiner
environment:
LOG_LEVEL: ${LOG_LEVEL:-info} # valor padrão se ausente
# Dois-pontos + ponto de interrogação: o Compose FALHA se a
# variável estiver ausente ou vazia. Use para todo segredo.
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET est obligatoire}
A forma ${VAR:?message} é a melhor proteção disponível: ela transforma um segredo esquecido em um erro imediato e explícito, em vez de uma aplicação que inicia com uma string vazia e falha na autenticação três horas depois.
Duas regras de segurança para encerrar. Um .env contendo segredos reais nunca entra no Git: versione um .env.example documentado e sem valores. E em produção, os segredos não passam por variáveis legíveis no docker inspect: usa-se a seção secrets, que os monta como arquivos em /run/secrets/, de modo que a aplicação lê um caminho em vez de um valor.
Profiles: serviços opcionais na mesma stack
Uma stack de desenvolvimento acaba acumulando ferramentas de que ninguém precisa o tempo todo: uma interface de administração do banco, um servidor SMTP falso, um gerador de carga. Iniciá-las sempre desperdiça memória; colocá-las em um segundo arquivo duplica a configuração. Um serviço associado a um profile só inicia se esse profile for solicitado.
services:
api:
build: .
ports:
- "3000:3000"
# Nenhum profile: este serviço sempre inicia
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
adminer: # administração do banco
image: adminer:4
profiles: ["debug"]
ports:
- "8080:8080"
mailpit: # servidor SMTP falso, em DOIS profiles
image: axllent/mailpit:latest
profiles: ["debug", "mail"]
ports:
- "8025:8025"
k6: # testes de carga, nunca por acidente
image: grafana/k6:latest
profiles: ["perf"]
command: ["run", "/scripts/charge.js"]
volumes:
- ./perf:/scripts:ro # :ro monta em somente leitura
docker compose up -d # apenas api + db
docker compose --profile debug up -d # + adminer + mailpit
docker compose --profile debug --profile perf up -d # profiles acumulados
COMPOSE_PROFILES=debug docker compose up -d # via variável de ambiente
docker compose --profile debug config --services # listar sem iniciar
Sobrepor vários arquivos
O mesmo projeto muitas vezes precisa rodar em contextos diferentes: código montado e hot reload em desenvolvimento, imagem fixa e limites de recursos em produção. O Compose sobrepõe os arquivos em vez de duplicá-los. Um arquivo chamado compose.override.yaml é carregado automaticamente além do compose.yaml.
# compose.yaml -- a base comum, válida em qualquer lugar
services:
api:
image: registre.exemple.com/equipe/mon-api:${TAG:-latest}
environment:
NODE_ENV: production
restart: unless-stopped
# compose.override.yaml -- carregado automaticamente, para desenvolvimento
services:
api:
build: . # construímos localmente
environment:
NODE_ENV: development
DEBUG: "app:*"
volumes:
# O código do host substitui o da imagem: uma modificação
# fica visível sem reconstruir.
- ./src:/app/src
# Volume anônimo: protege o node_modules da imagem de ser
# sobrescrito pelo do host.
- /app/node_modules
command: ["npm", "run", "dev"]
ports:
- "9229:9229" # porta do depurador do Node
# compose.prod.yaml -- carregado explicitamente
services:
api:
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Desenvolvimento: compose.yaml + compose.override.yaml, automaticamente
docker compose up -d
# Produção: os arquivos são nomeados, o override NÃO é carregado
docker compose -f compose.yaml -f compose.prod.yaml up -d
# Sempre verifique o resultado da mesclagem antes de fazer o deploy
docker compose -f compose.yaml -f compose.prod.yaml config
É importante conhecer a regra de mesclagem: para valores simples, o último arquivo vence; para listas como ports ou volumes, as entradas são acumuladas. É uma surpresa frequente, que docker compose config revela imediatamente.
O que o Compose não faz
O Compose orquestra contêineres em um único host. Ele não distribui nada entre várias máquinas, não reinicia um serviço em outro lugar quando um servidor cai e não garante nenhum deploy sem interrupção. Isso é domínio do Kubernetes ou do Docker Swarm.
Isso não o desqualifica para produção: um único servidor gerenciado com Compose é uma arquitetura perfeitamente defensável para um serviço de tráfego moderado, e muito mais simples de operar do que um cluster. A lição 6 trata justamente desse caso.
A escala local existe, no entanto, e é útil para testar um worker:
# Três instâncias do serviço worker
docker compose up -d --scale worker=3
Ela é incompatível com a publicação de uma porta fixa: três contêineres não podem se vincular à porta 3000 do host. Remova a chave ports do serviço em questão ou deixe o Docker escolher portas livres.
Pontos-chave
- Um
compose.yamlversionado substitui uma coleção dedocker runfrágeis: a definição da aplicação passa a ser um documento único, revisado em code review e reproduzível por toda a equipe. - Use
docker composeem duas palavras.docker-composecom hífen é a v1, descontinuada, que ignora parte da sintaxe atual. A chaveversion:no topo do arquivo é obsoleta e não tem efeito. - Os serviços se comunicam pelo nome graças ao DNS interno. Publique com
portsapenas o que precisa ser acessível externamente: um banco de dados geralmente não faz parte disso. depends_onsozinho garante apenas uma ordem de inicialização. Para esperar a disponibilidade real, adicione umhealthcheckecondition: service_healthy;service_completed_successfullyresolve bem o caso das migrações.docker compose upnão reconstrói suas imagens: lembre-se de usar--buildapós qualquer modificação em um Dockerfile. Edocker compose down -vremove os volumes, ou seja, os dados.${VAR:?message}transforma um segredo ausente em erro imediato. Em caso de dúvida sobre uma variável,docker compose configexibe o arquivo realmente interpretado.- Os profiles isolam as ferramentas opcionais e os arquivos de sobrescrita adaptam a mesma stack para desenvolvimento ou produção, sem duplicar a configuração.
Dica: adquira o hábito de executar
docker compose configantes de qualquerupem uma configuração modificada. A maioria das horas perdidas com o Compose se resume à diferença entre o que você acha que escreveu e o que a interpolação realmente produziu.
Comentários
Os comentários são alimentados pelo GitHub Discussions
Conecte-se com o GitHub para participar da discussão