Docker Compose para Desenvolvimento Local

docker compose resolve um problema muito específico que todo time de desenvolvimento e operação conhece bem: subir uma stack local que se comporte de forma previsível, sem transformar a máquina do desenvolvedor em um zoológico de dependências soltas. Em ambiente linux, isso ganha ainda mais peso porque a integração com kernel, redes, volumes e permissões é direta. Quando bem estruturado, o Compose vira a base de um dev environment que replica serviços reais, reduz discrepâncias entre laptops e facilita automação com scripts, makefile e pipelines.

O ponto central não é “rodar containers”. É modelar uma topologia local de forma declarativa, com serviços isolados, redes explícitas, persistência controlada e parâmetros parametrizáveis por ambiente. Essa abordagem encaixa perfeitamente no fluxo de trabalho DevOps: o mesmo arquivo descreve infraestrutura local, e boa parte dele pode ser reaproveitada em testes automatizados, validação de integração e até em ambientes efêmeros de preview.

Por que Compose faz sentido no desenvolvimento local

Em vez de instalar banco, cache, filas e runtimes diretamente no host, o Compose encapsula tudo em containers versionados. Isso elimina conflito de dependências, evita poluição do sistema e simplifica rollback. Em um workstation Linux, o ganho operacional é imediato: a stack sobe com um comando e desce com outro, mantendo a máquina limpa.

Há três benefícios práticos que justificam adoção séria. Primeiro, reprodutibilidade: a mesma versão de PostgreSQL, Redis, RabbitMQ ou Node roda para todo mundo. Segundo, isolamento: cada projeto mantém suas portas, redes e volumes. Terceiro, automação: arquivos YAML, scripts Bash e variáveis de ambiente permitem padronizar o fluxo do time.

O Compose também serve como documentação executável. Um novo membro do time lê a árvore do repositório, vê o compose.yaml, entende quais serviços existem e sobe a stack sem depender de conhecimento tribal. Em times que trabalham com Linux e DevOps, isso reduz tempo de onboarding e minimiza variação operacional.

Pré-requisitos no host Linux e decisões que evitam dor

O primeiro detalhe é escolher uma instalação estável do Docker Engine e do plugin de Compose. Em distribuições modernas, o fluxo preferível é usar o pacote oficial do repositório do Docker, com o Compose v2 como plugin. A adoção do binário docker compose simplifica integrações e evita diferenças entre versões antigas do executável separado.

# Exemplo em distribuições baseadas em Debian/Ubuntu
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo 
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu 
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | 
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"

Em Fedora, Rocky Linux ou outras bases RPM, o princípio é o mesmo: repositório oficial, plugin Compose e permissão de acesso ao socket. Em ambientes corporativos, muitas vezes o acesso ao daemon é controlado por grupo LDAP ou por políticas de segurança locais. Vale tratar isso cedo, porque permissões incorretas em /var/run/docker.sock quebram o fluxo de dev e levam o time a usar sudo demais, o que aumenta atrito e risco.

Outro ponto: no Linux, atenção a arquivos montados no bind mount. UID e GID importam. Se o container escreve como root e o host espera um usuário comum, surgem problemas de permissão em logs, cache e diretórios de trabalho. Uma estratégia robusta é alinhar UID/GID via variáveis de ambiente ou usar um usuário não-root dentro do container.

Estrutura mínima de um Compose bem projetado

Um erro clássico é escrever um compose.yaml que sobe, mas não é legível nem extensível. A estrutura abaixo já cobre uma API, um banco e um Redis de apoio, com rede interna e volumes nomeados. O objetivo é separar responsabilidades e deixar explícito o que persiste e o que é efêmero.

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: dev-app
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    environment:
      APP_ENV: development
      DATABASE_URL: postgresql://app:app@db:5432/appdb
      REDIS_URL: redis://redis:6379/0
    ports:
      - "8080:8080"
    volumes:
      - ./:/workspace:delegated
    networks:
      - backend

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 20
    networks:
      - backend

  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redisdata:/data
    networks:
      - backend

volumes:
  pgdata:
  redisdata:

networks:
  backend:
    driver: bridge

Esse modelo já entrega pontos importantes. A aplicação depende do banco ficar saudável, não apenas “iniciada”. O banco persiste em volume nomeado. O Redis também persiste, caso o uso exija AOF. A rede backend evita exposição desnecessária entre serviços e mantém o tráfego interno isolado do host.

O parâmetro :delegated em bind mount é relevante em determinados cenários de Docker Desktop, mas em Linux puro o efeito pode variar conforme versão e filesystem. A ideia geral permanece: reduzir custo de sincronização entre host e container em ambientes de desenvolvimento com muitos arquivos. Mesmo assim, antes de aplicar otimizações, valide comportamento do seu kernel, do overlayFS e da carga de I/O da aplicação.

Dockerfile voltado para desenvolvimento, não produção disfarçada

Compose normalmente aponta para uma imagem construída localmente. O erro recorrente é usar um Dockerfile de produção para desenvolvimento e depois reclamar da lentidão de rebuild. O ideal é criar uma camada específica para o fluxo local, com ferramentas de debug, hot reload e dependências de dev instaladas.

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 
    PYTHONUNBUFFERED=1 
    PIP_NO_CACHE_DIR=1

WORKDIR /workspace

RUN apt-get update 
    && apt-get install -y --no-install-recommends 
       build-essential 
       curl 
       git 
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --upgrade pip && pip install -r requirements.txt

COPY . .

CMD ["python", "app.py"]

Se a aplicação é Node.js, o padrão muda, mas a lógica é idêntica: cache de dependências antes do código-fonte, volume para live reload e comando de inicialização voltado ao modo dev. Em Go, o container pode carregar air ou reflex para rebuild automático. Em Java, vale separar o JDK de desenvolvimento do runtime final e usar um perfil com reloader compatível.

Para stacks mais complexas, vale usar .dockerignore agressivo. Isso acelera o contexto de build e impede que artefatos locais entrem na imagem por engano.

.git
.gitignore
node_modules
venv
__pycache__
.coverage
.dist
build
.tmp
Dockerfile
compose.override.yaml

Variáveis de ambiente, overrides e arquivos por perfil

Compose funciona melhor quando o arquivo principal contém o esqueleto e os detalhes variáveis ficam em arquivos auxiliares. O padrão mais limpo é usar .env, compose.override.yaml e, quando necessário, arquivos específicos por time ou por cenário.

O arquivo .env guarda parâmetros locais que não precisam entrar no controle de versão. Exemplo:

APP_PORT=8080
POSTGRES_DB=appdb
POSTGRES_USER=app
POSTGRES_PASSWORD=app
POSTGRES_PORT=5432

No YAML, a interpolação deixa a stack flexível sem transformar o Compose em uma colcha de retalhos. Um exemplo com env_file e overrides:

services:
  app:
    env_file:
      - .env
    ports:
      - "${APP_PORT:-8080}:8080"
    volumes:
      - ./:/workspace
    command: ["python", "app.py"]

Para desenvolvimento local, compose.override.yaml costuma adicionar mounts, comandos de debug e ferramentas de observabilidade. O Compose carrega esse arquivo automaticamente quando está ao lado do principal. Isso permite manter uma base comum e um comportamento estendido sem duplicar toda a definição.

services:
  app:
    command: ["python", "-m", "debugpy", "--listen", "0.0.0.0:5678", "app.py"]
    ports:
      - "5678:5678"
    environment:
      LOG_LEVEL: debug

Healthchecks, dependências e inicialização sem corrida

Ambientes locais quebram com frequência por race condition: a aplicação sobe antes de o banco aceitar conexão. depends_on sozinho não resolve isso, porque ele ordena start, mas não garante prontidão. O uso de healthcheck e condition: service_healthy fecha essa brecha.

No caso de serviços que dependem de migrações, o fluxo ideal não é enfiar tudo no entrypoint da aplicação sem critério. Melhor criar um serviço de inicialização ou rodar um comando explícito antes de subir a API. Isso mantém o processo transparente e facilita troubleshooting.

docker compose up -d db redis
until docker compose exec -T db pg_isready -U app -d appdb; do
  echo "aguardando postgres..."
  sleep 2
done
docker compose run --rm app python manage.py migrate
docker compose up -d app

Em stacks com múltiplos consumidores, vale separar um serviço de migrations ou bootstrap com restart: "no". Isso deixa claro que o objetivo é executar tarefas pontuais, não manter um processo persistente.

Volumes, bind mounts e o impacto real em performance no Linux

O modelo de armazenamento define a experiência de desenvolvimento. Volumes nomeados são adequados para dados que persistem independentemente do estado do projeto. Bind mounts servem para refletir alterações do código-fonte em tempo real. Em Linux, essa divisão costuma funcionar bem, mas há nuances.

Se a aplicação lê milhares de arquivos em tempo de inicialização — algo comum em frameworks PHP, Node e Python — o bind mount pode gerar overhead perceptível. Estratégias práticas incluem reduzir o tamanho do contexto do projeto, excluir diretórios pesados, ativar watchers mais inteligentes e usar ferramentas como inotify de forma consciente. Em alguns casos, compensa montar apenas diretórios de trabalho específicos em vez da árvore inteira.

Para bancos, sempre prefira volume nomeado. Para caches, depende do fluxo. Se o cache acelera a execução mas não faz parte do estado do sistema, vale considerar um volume efêmero ou até um tmpfs quando a stack local precisa máxima velocidade.

services:
  app:
    tmpfs:
      - /tmp
      - /run

Tmpfs é útil em testes e em serviços que escrevem muito em disco temporário. Em Linux, essa abordagem reduz I/O e melhora latência, desde que a aplicação não dependa da persistência desses caminhos.

Logs, inspeção e depuração sem adivinhação

Um Compose bem montado também precisa facilitar observabilidade local. Logs devem ir para stdout/stderr, nunca para arquivos opacos dentro do container, a menos que exista um motivo específico. Isso permite usar o fluxo natural de docker compose logs e integra bem com agregadores locais, como jq, grep e stern-like workflows em escala maior.

docker compose ps
docker compose logs -f app
docker compose logs --tail=100 db
docker compose exec app sh

docker inspect $(docker compose ps -q db) | jq '.[0].State.Health'

Quando algo falha, inspecionar rede e resolução de nomes ajuda muito. Compose cria DNS interno para os serviços; dentro da rede, db resolve para o container do PostgreSQL. Se a aplicação não conecta, o diagnóstico deve incluir variável de ambiente, rota, porta, healthcheck, backlog de inicialização e permissões de usuário. Em muitos casos, o problema não está no Compose, mas no driver do cliente, no schema de conexão ou em migrações pendentes.

Para depuração interativa, docker compose exec é melhor que abrir shell com docker exec manual porque mantém o contexto do projeto e reduz erro de alvo. Em containers mínimos baseados em Alpine ou distroless, talvez nem exista bash; nesse caso, sh ou uma imagem de debug separada resolve o acesso.

Perfil de desenvolvimento, testes e serviços auxiliares

Projetos maduros não mantêm apenas uma stack. Frequentemente existem perfis para desenvolvimento, testes de integração e suporte local. O Compose permite modelar isso sem duplicar arquivos inteiros. O recurso profiles ajuda a ativar serviços sob demanda.

services:
  mailhog:
    image: mailhog/mailhog:latest
    ports:
      - "1025:1025"
      - "8025:8025"
    profiles: ["dev"]

  worker:
    build: .
    command: ["python", "worker.py"]
    profiles: ["dev", "test"]

Essa organização evita carregar serviços desnecessários em todo docker compose up. Em desenvolvimento, o time ativa o que precisa. Em CI local, o mesmo arquivo pode ser usado com outro perfil, reduzindo divergência entre máquina do desenvolvedor e pipeline.

Também vale considerar serviços de apoio como SMTP fake, MinIO, Elastic ou Loki, dependendo do tipo de aplicação. A regra é simples: só entra no Compose o que afeta diretamente o ciclo de feedback local. Se um serviço não melhora depuração ou validação, ele só adiciona peso.

Automação com Makefile e scripts Bash

Compose fica muito mais útil quando combinado com comandos curtos e consistentes. O objetivo é eliminar passos manuais e reduzir memória operacional. Um Makefile ou um script Bash encapsula os comandos repetitivos, padroniza nomes e evita opções diferentes entre integrantes do time.

COMPOSE=docker compose

up:
	$(COMPOSE) up -d --build

down:
	$(COMPOSE) down

logs:
	$(COMPOSE) logs -f --tail=200

ps:
	$(COMPOSE) ps

shell:
	$(COMPOSE) exec app sh

migrate:
	$(COMPOSE) run --rm app python manage.py migrate

reset:
	$(COMPOSE) down -v --remove-orphans

Em Bash, um wrapper melhora a experiência quando há múltiplos arquivos de Compose, como base, override e perfil específico.

#!/usr/bin/env bash
set -euo pipefail

COMPOSE_FILES=(-f compose.yaml -f compose.override.yaml)

docker compose "${COMPOSE_FILES[@]}" up -d --build

Esse tipo de automação cria previsibilidade. O desenvolvedor não precisa memorizar flags. O time não debate variações. A stack sobe sempre do mesmo jeito, e isso é valioso em projetos com várias dependências ou com onboarding frequente.

Segurança local sem exagero e sem ingenuidade

Mesmo em ambiente de desenvolvimento, segurança importa. Rodar tudo como root dentro dos containers é um atalho ruim. Expor portas sem necessidade também é. O modelo correto é limitar a superfície de acesso ao mínimo viável.

Use usuários não privilegiados quando possível. Evite montar diretórios sensíveis do host. Defina redes separadas para isolar serviços de backend e serviços públicos. Não coloque senhas em claro no repositório se o projeto já exige algum cuidado real; use .env local ignorado por Git, secrets quando fizer sentido e mecanismos de injeção no pipeline. Em stacks locais simples, a exigência de segredo pesado pode ser excesso, mas a higiene básica é obrigatória.

services:
  app:
    user: "1000:1000"
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

Nem todo serviço tolera read_only imediatamente, mas o padrão é útil para identificar dependências implícitas de escrita. Em desenvolvimento, isso revela suposições ruins cedo, antes que virem problemas em produção. Esse tipo de disciplina é o que separa um Compose apenas funcional de um Compose realmente útil em práticas DevOps.

Um exemplo completo para projeto web com API, banco e cache

Juntando as peças, uma stack local limpa pode ficar assim:

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    environment:
      DATABASE_URL: postgresql://app:app@postgres:5432/appdb
      CACHE_URL: redis://redis:6379/0
      PORT: 8080
    ports:
      - "8080:8080"
    volumes:
      - ./:/workspace
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - internal

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 20
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - internal

  redis:
    image: redis:7-alpine
    networks:
      - internal

volumes:
  postgres_data:

networks:
  internal:
    driver: bridge

Esse Compose já atende boa parte dos cenários de desenvolvimento local. Se a aplicação usa migrações, crie um comando explícito. Se precisa de hot reload, substitua o comando principal por um watcher. Se o time quer testes automatizados, introduza um perfil ou serviço de test que execute a suíte em container, com banco isolado e volumes temporários.

Para validar a stack, rode o seguinte fluxo:

docker compose up -d --build
docker compose ps
docker compose logs -f api
curl -i http://localhost:8080/health

Se o endpoint de saúde responde e o banco mantém persistência entre reinícios, a base está correta. O passo seguinte é integrar esse Compose ao fluxo do repositório: README curto, comandos padronizados, variáveis documentadas e serviços auxiliares claros. O resultado é um ambiente local que respeita a realidade de operação sem carregar complexidade desnecessária.

Quando o Compose deixa de ser suficiente

Há um limite natural. Se o projeto demanda simulação de cluster, service mesh, múltiplos nós, políticas de rede sofisticadas ou orquestração distribuída realista, o Compose já não cobre tudo. Nesses casos, a estratégia muda para Kubernetes local, Kind, K3d ou ambientes efêmeros em CI. Ainda assim, o Compose continua útil como etapa de desenvolvimento rápido e validação funcional.

O critério técnico é objetivo: se a topologia local precisa reproduzir detalhes de agendamento, tolerâncias, autoscaling ou múltiplos nós, use outra ferramenta. Se a necessidade é subir dependências, validar integração e acelerar feedback do desenvolvedor, Docker Compose é a ferramenta certa. No Linux, com o daemon bem configurado, a experiência é estável e previsível.

O valor real está em tratar o Compose como código de infraestrutura local. Arquivo versionado, convenções claras, automação ao redor e inspeção direta via CLI. Esse conjunto reduz atrito, melhora a qualidade do ambiente de dev e cria uma ponte técnica consistente entre workstation e plataforma.