Dia 22: Docker Compose — Multi-Service Stacks e Orquestração Básica

No Dia 21 cobrimos o Docker em isolado: construir imagens, gerir volumes e ligar containers com redes. Mas um stack real não é um container — é uma app web que precisa de uma base de dados, um cache e um reverse proxy. Ligar quatro containers com docker run na mão produz comandos de 200 caracteres que ninguém consegue reproduzir.

O Docker Compose resolve isso: descreve toda a stack num ficheiro YAML declarativo e sobe os serviços com um único comando. Este guia cobre a versão V2 do plugin docker compose (subcomando nativo do Docker CLI), a sintaxe do docker-compose.yml, redes e volumes declarativos, profiles para ambientes selectivos e os erros que aparecem quando se migra do docker run manual.

Neste artigo

Introdução: docker compose vs docker run

O docker run trata de um container de cada vez. Para ligar três containers — app, base de dados e cache — é preciso criar a rede manualmente, dar nomes aos containers, passar variáveis de ambiente em flags longas e garantir a ordem de arranque. O comando fica assim:

docker network create app-net

docker run -d --name db --network app-net \
  -e POSTGRES_PASSWORD=secret \
  -v db-data:/var/lib/postgresql/data \
  postgres:16

docker run -d --name redis --network app-net \
  redis:7-alpine

docker run -d --name app --network app-net \
  -e DATABASE_URL=postgres://user:secret@db:5432/myapp \
  -e REDIS_URL=redis://redis:6379 \
  -p 8080:3000 \
  myapp:latest

Se o servidor reiniciar, ou um colega precisar de reproduzir o ambiente, esses comandos desaparecem — só existem no histórico do terminal. O Docker Compose resolve o problema ao descrever toda a stack num ficheiro docker-compose.yml versionado no Git. Um comando sobe tudo, na ordem certa, com redes e volumes declarados.

A diferença fundamental: docker run é imperativo (diz o que fazer); docker compose é declarativo (diz o estado desejado). O ficheiro YAML é a fonte de verdade: se um container crasha, o up recria-o com a configuração definida no ficheiro, não com a que estava em memória (Docker Compose documentation).

Nota: Este artigo cobre o Docker Compose V2 — o plugin docker compose (sem hífen) integrado no Docker CLI. O Python docker-compose V1 (com hífen) está deprecated desde Junho de 2023 e removido em versões recentes. A partir daqui, todos os comandos usam docker compose (sem hífen).

Instalar Docker Compose

O Docker Compose V2 vem como plugin no pacote docker-ce na maioria das distribuições. Se o Docker já está instalado (como vimos no Dia 21), o Compose já deve estar disponível. Verificar:

docker compose version

Se devolver Docker Compose version v2.x, está pronto. Se o comando não existe, instalar o plugin:

Ubuntu/Debian (repositório oficial Docker):

apt install docker-compose-plugin

Rocky/Alma/RHEL/Fedora:

dnf install docker-compose-plugin

Instalação manual (binário único, qualquer distro):

mkdir -p ~/.docker/cli-plugins
curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m) \
  -o ~/.docker/cli-plugins/docker-compose
chmod +x ~/.docker/cli-plugins/docker-compose

A instalação manual coloca o binário em ~/.docker/cli-plugins/ para o utilizador corrente. Para sistema, usar /usr/libexec/docker/cli-plugins/ em RHEL ou /usr/lib/docker/cli-plugins/ em Debian (Install Docker Compose).

Dica: O Compose V2 usa o prefixo docker compose (espaço, sem hífen). Se ainda tens scripts com docker-compose (hífen), criar um alias: alias docker-compose='docker compose' em ~/.bashrc.

docker-compose.yml: serviços, redes, volumes e environment

O ficheiro docker-compose.yml é a espinha dorsal do Compose. Define serviços (containers), redes (isolamento L2), volumes (persistência) e environment (configuração). A versão da sintaxe deixou de ser obrigatória — o campo version: foi removido na especificação actual do Compose e é ignorado se presente.

Exemplo completo — stack web com PostgreSQL, Redis e app Node.js:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: myapp
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    networks:
      - backend

  app:
    build: .
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    environment:
      DATABASE_URL: postgres://myapp:${DB_PASSWORD}@db:5432/myapp
      REDIS_URL: redis://redis:6379
    ports:
      - "8080:3000"
    volumes:
      - ./src:/app/src
    networks:
      - backend
      - frontend
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app
    networks:
      - frontend
    profiles:
      - production

volumes:
  db-data:

networks:
  backend:
    driver: bridge
  frontend:
    driver: bridge

Serviços

Cada chave sob services: é o nome do serviço — esse nome torna-se o hostname DNS dentro das redes do Compose. O serviço db é reachável como db a partir do app. Não há IP para decorar — o DNS interno do Docker resolve os nomes dos serviços automaticamente.

Os campos mais usados: image para imagens prontas do registry, build para construir a partir de um Dockerfile, ports para mapear portas host:container, depends_on para ordem de arranque e restart para a política de restart automático.

depends_on e healthchecks

O depends_on sozinho só garante a ordem de arranque — não espera que o serviço esteja pronto para receber ligações. Para esperar de verdade, combinar com healthcheck e usar condition: service_healthy. No exemplo acima, o app só arranca depois do pg_isready do PostgreSQL devolver sucesso. Sem isto, a app tenta ligar à BD antes de ela estar pronta e crasha em loop (Compose service reference).

Redes

As redes definidas no topo criam bridge networks isoladas. No exemplo, db e redis estão na rede backend, o nginx na frontend e o app em ambas — funciona como bridge entre as duas. O nginx não consegue chegar ao db directamente, o que é exactamente o pretendido: o reverse proxy só fala com a app, nunca com a base de dados.

Se nenhuma rede for declarada, o Compose cria uma rede predefinida com o nome do projecto (nome do directório em minúsculas) e liga todos os serviços a ela. Declarar redes explícitas é boa prática para micro-segmentação.

Volumes

Há dois tipos: named volumes (declarados no topo, geridos pelo Docker, persistem entre down/up) e bind mounts (caminhos do hospedeiro montados directamente, úteis em desenvolvimento para hot-reload). No exemplo, db-data é um named volume para os dados do PostgreSQL; ./src:/app/src é um bind mount que monta o código-fonte do host no container para desenvolvimento. O sufixo :ro no volume do nginx torna a montagem read-only (Compose volumes reference).

Environment

Há três formas de passar variáveis de ambiente: environment: inline no YAML, env_file: para carregar de um ficheiro .env e a interpolação ${VAR} que lê do ambiente do shell ou de um ficheiro .env no mesmo directório. O .env é automaticamente lido pelo Compose — não precisa de ser referenciado:

# .env (na mesma pasta do docker-compose.yml)
DB_PASSWORD=minha-senha-secreta
NODE_ENV=production
PORT=3000

A interpolação ${DB_PASSWORD} no YAML é substituída pelo valor do .env. Isto evita credenciais no ficheiro versionado — adicionar .env ao .gitignore e commitar apenas um .env.example com valores de placeholder (Environment variables in Compose).

Atenção: Nunca commitar o ficheiro .env com credenciais reais. O Compose lê-o automaticamente, mas o Git não deve vê-lo. Usar .env.example versionado e .env no .gitignore.

Comandos: up, down, logs, exec e profiles

Todos os comandos correm a partir do directório onde está o docker-compose.yml. O nome do directório torna-se o prefixo do projecto — todos os containers, redes e volumes recebem esse prefixo. Para usar um nome diferente, usar -p nome ou definir COMPOSE_PROJECT_NAME.

up — subir a stack

# Sobe todos os serviços em foreground (ver logs em tempo real)
docker compose up

# Modo detached (background) — devolve o terminal
docker compose up -d

# Forçar rebuild das imagens locais
docker compose up -d --build

# Só um serviço específico (+ dependências)
docker compose up -d db

# Recriar containers mesmo sem mudanças no YAML
docker compose up -d --force-recreate

O up é idempotente: se já está a correr, aplica apenas as diferenças. Se o YAML mudou, recria apenas os serviços afetados. Se nada mudou, não faz nada.

down — parar e remover

# Para e remove containers, redes (NÃO remove volumes)
docker compose down

# Remove também named volumes (PERIGOSO — apaga dados!)
docker compose down -v

# Remove também imagens usadas
docker compose down --rmi local

# Remove imagens, volumes e orphans (containers não definidos no YAML)
docker compose down -v --rmi all --remove-orphans
Aviso: O down -v elimina os named volumes permanentemente. A base de dados, o estado do Redis, tudo o que está em volumes declarados é apagado. Usar apenas em ambientes de desenvolvimento descartáveis.

logs — seguir output

# Logs de todos os serviços (live)
docker compose logs -f

# Logs de um serviço específico
docker compose logs -f app

# Últimas 50 linhas e segue
docker compose logs -f --tail 50 app

# Com timestamps
docker compose logs -t app

exec — comandos dentro do container

# Abrir shell no container
docker compose exec app sh

# Executar comando directo
docker compose exec db psql -U myapp -d myapp

# Como root (override do user)
docker compose exec --user root app sh

O exec requer que o container esteja a correr. Para arrancar um serviço parado sem subir toda a stack, usar docker compose start app primeiro.

profiles — ambientes selectivos

Os profiles permitem ter serviços opcionais no mesmo docker-compose.yml que só sobem quando explicitamente pedidos. No exemplo acima, o nginx tem profiles: [production] — não sobe com docker compose up normal. Só quando se ativa o profile:

# Subir só os serviços do profile production
docker compose --profile production up -d

# Combinar via variável de ambiente
COMPOSE_PROFILES=production docker compose up -d

# Ver que serviços estão definidos
docker compose config --profiles

Cenário típico: usar profiles para separar debug (com ferramentas de profiling), production (com nginx e certificados) e test (com mock services) — tudo num único ficheiro, sem manter múltiplas cópias (Compose profiles).

Outros comandos essenciais

# Estado dos serviços
docker compose ps

# Validar o YAML e mostrar config resolvida
docker compose config

# Ver consumo de recursos
docker compose stats

# Parar sem remover (preserva containers)
docker compose stop

# Arrancar serviços parados
docker compose start

# Reiniciar um serviço
docker compose restart app

# Rebuild de imagens
docker compose build
docker compose build --no-cache app

Erros Comuns

Erro Causa Solução
Bind address already in use A porta do host já está ocupada por outro processo ou container anterior docker compose down para limpar; se persistir, ss -tlnp | grep PORTA para identificar o processo; mudar o mapeamento de porta no YAML
no configuration file provided: not found O comando foi executado fora do directório com o docker-compose.yml Usar -f caminho/para/docker-compose.yml ou cd para o directório correcto
service "db" has neither an image nor a build context O serviço não tem image: nem build: definidos Adicionar image: imagem:tag ou build: . ao serviço
dependency failed to start: container db is unhealthy O healthcheck falha e o depends_on: condition: service_healthy bloqueia o arranque Verificar docker compose logs db; ajustar o comando test: do healthcheck ou aumentar retries: e interval:
Variable is not set. Defaulting to a blank string A interpolação ${VAR} não encontra a variável no ambiente nem no .env Criar/editar o ficheiro .env no mesmo directório; usar ${VAR:-default} para fallback inline
network not found ao fazer up Um container órfão de uma execução anterior ainda referencia a rede antiga docker compose down --remove-orphans para limpar containers órfãos; se persistir, docker network prune
Cannot connect to the Docker daemon O serviço Docker não está a correr ou o utilizador não está no grupo docker sudo systemctl start docker; adicionar utilizador: usermod -aG docker $USER e fazer re-login
YAML inválido mas sem mensagem clara Indentação inconsistente (tabs misturados com espaços) ou chave sem valor docker compose config valida o YAML e mostra o erro exacto; usar sempre espaços (2 por nível), nunca tabs
Dados perdidos após down Foi usado down -v ou os dados estavam num bind mount que foi removido Usar down sem -v para preservar named volumes; para bind mounts, garantir que o caminho do hospedeiro não é temporário
Containers não se veem (DNS não resolve) Serviços em redes diferentes sem bridge comum Verificar networks: de cada serviço no YAML; colocar ambos na mesma rede ou numa rede partilhada

Artigos Relacionados